@brunobracaioli/aibrain 0.3.0-beta.13 → 0.3.0-beta.14

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
@@ -3,11 +3,22 @@
3
3
  Assisted AI Brain context beta client for Linux/WSL with Node.js 22 or newer.
4
4
 
5
5
  The reviewed native compatibility set is Claude Code 2.1.294, 2.1.295, 2.1.296,
6
- 2.1.299, 2.1.300, 2.1.301 or 2.1.302, and Codex CLI 0.161.0,
6
+ 2.1.299, 2.1.300, 2.1.301, 2.1.302 or 2.1.303, and Codex CLI 0.161.0,
7
7
  0.162.0, 0.162.1 or 0.163.0. A newer stable Codex 0.x release may continue only
8
8
  after its locally generated experimental protocol schemas and transport flags
9
9
  match the bundled adapter contract; prereleases, older releases and new majors
10
- remain rejected.
10
+ remain rejected. Starting with beta14, newer stable Claude 2.1 patches can
11
+ continue after isolated strict plugin validation, required hook/API checks and
12
+ an enabled-hook test in the native offline runner. Doctor distinguishes
13
+ `host_checked` from `qualified`; this check does not establish full behavioral
14
+ equivalence with a tested native release. Unknown older patches, prereleases
15
+ and other major/minor families remain rejected. No credentials, conversations,
16
+ model requests or JEV calls are used by these compatibility probes.
17
+
18
+ The Codex terminal bridge permits the native session picker's separate read-only
19
+ connection while keeping the primary conversation and monitor intact. A picker
20
+ cannot compact, start a turn, inject history or take ownership of the primary
21
+ connection. Its failure or timeout closes only that auxiliary connection.
11
22
 
12
23
  ```sh
13
24
  # Install the published beta:
@@ -57,7 +68,7 @@ text; they do not claim that a live session is being monitored. Codex keeps nati
57
68
  compaction and recovery notes in the same thread. The backend checks paid access
58
69
  and unchanged shared quotas before any JEV request.
59
70
 
60
- The `0.3.0-beta.13` Claude launcher uses the durable context strategy and
71
+ The `0.3.0-beta.14` Claude launcher uses the durable context strategy and
61
72
  records bounded recovery reasons when native summary proof cannot be confirmed:
62
73
  JEV selects compatible historical tool evidence, bounded private notes retain
63
74
  the selected text, and Claude core summarizes the reduced input. The next
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-brain-context-compaction",
3
- "version": "0.3.0-beta.13",
3
+ "version": "0.3.0-beta.14",
4
4
  "author": { "name": "AISAC" },
5
5
  "description": "AI Brain paid-plan context monitoring with conservative Claude compaction fallback.",
6
6
  "hooks": "./hooks/hooks.json",
@@ -0,0 +1,18 @@
1
+ import { register as registerContext } from './context-compaction.ts';
2
+
3
+ export function register(on: Parameters<typeof registerContext>[0]): void {
4
+ return registerContext(on, {
5
+ enabled: true,
6
+ disclosed: true,
7
+ policyVersion: 2,
8
+ compactAtPercent: 60,
9
+ keepThreshold: 0.5,
10
+ preserveRecentMessages: 6,
11
+ minReductionRatio: 0.25,
12
+ truncateHeadChars: 300,
13
+ maxStateTokens: 25_000,
14
+ maxRequestTokens: 30_000,
15
+ compactionStrategy: 'native_summary_notes',
16
+ preservationMode: 'upstream_compatible',
17
+ });
18
+ }
@@ -0,0 +1,42 @@
1
+ import { expect, test } from 'claude-code/testing';
2
+
3
+ const contractTest: Parameters<typeof test>[2] = async ($, on) => {
4
+ on('prompt.submit', () => ({ text: 'native-host-contract-ok' }));
5
+ on('tool.call', () => ({ result: { content: 'native-host-tool-ok' } }));
6
+ on('session.messages', () => ({ value: [] }));
7
+ let usageCalls = 0;
8
+ on('session.usage', () => ({
9
+ value: (() => {
10
+ usageCalls += 1;
11
+ return { context: { percent: 90, tokens: 900, window: 1_000 } };
12
+ })(),
13
+ }));
14
+ on('ui.log', () => ({ value: undefined }));
15
+ on('ui.toast', () => ({ value: undefined }));
16
+ let compactCalls = 0;
17
+ on('session.compact', () => {
18
+ compactCalls += 1;
19
+ return {
20
+ messages: [{ role: 'assistant', text: 'native-summary-contract-ok', toolUses: [] }],
21
+ };
22
+ });
23
+ on('turn.complete', () => ({ text: '' }));
24
+ const prompt = await $.prompt.submit({ text: 'native-host-contract-input' });
25
+ const tool = await $.tool.call({ tool: 'Read', file_path: 'native-host-contract.txt' });
26
+ await $.turn.complete({
27
+ turnId: 'native-contract-turn',
28
+ answer: 'native-turn-contract-ok',
29
+ durationMs: 1,
30
+ isAborted: false,
31
+ usage: null,
32
+ });
33
+ expect(prompt.text).toBe('native-host-contract-ok');
34
+ expect(tool.result.content).toBe('native-host-tool-ok');
35
+ expect(usageCalls).toBe(1);
36
+ expect(compactCalls).toBe(1);
37
+ };
38
+
39
+ test(
40
+ 'AI Brain enabled host contract invokes usage and native compaction through the dispatcher',
41
+ contractTest,
42
+ );
@@ -12,6 +12,11 @@ const MAX_FRAME_BYTES = 16 * 1024 * 1024;
12
12
  const MAX_QUEUED_FRAMES = 64;
13
13
  const MAX_QUEUED_BYTES = 32 * 1024 * 1024;
14
14
  const STARTUP_HANDOFF_GRACE_MS = 5_000;
15
+ const MAX_AUXILIARY_CONNECTIONS = 1;
16
+ const MAX_AUXILIARY_MESSAGES = 64;
17
+ const MAX_AUXILIARY_IN_FLIGHT = 32;
18
+ const MAX_AUXILIARY_NOTIFICATIONS = 256;
19
+ const DEFAULT_AUXILIARY_IDLE_TIMEOUT_MS = 120_000;
15
20
  const NORMAL_DISCOVERY_CLOSE_CODES = new Set([1000, 1001, 1005]);
16
21
 
17
22
  function repoRoot() {
@@ -60,7 +65,13 @@ async function main() {
60
65
  const codexBinary = process.env.CODEX_BIN ?? 'codex';
61
66
  const nativeArgs =
62
67
  process.argv.slice(2)[0] === '--' ? process.argv.slice(3) : process.argv.slice(2);
63
- const allowStartupHandoff = nativeArgs[0] === 'resume';
68
+ // The bridge owns an interactive Codex TUI transport. Any native command
69
+ // that reaches this bridge may perform a short-lived read-only bootstrap
70
+ // before attaching its conversation transport. The protocol boundary below
71
+ // is the authority: initialize, same-client identity, successful allowlisted
72
+ // discovery, no started operation, one candidate, and a five-second deadline.
73
+ // Non-interactive Codex subcommands never open the socket and exit normally.
74
+ const allowStartupHandoff = true;
64
75
  const childEnv = { ...process.env };
65
76
  delete childEnv.JEV_API_KEY;
66
77
  delete childEnv.TYPESAFE_API_KEY;
@@ -71,11 +82,19 @@ async function main() {
71
82
  const generations = new Set();
72
83
  let activeConnection = null;
73
84
  let pendingCandidate = null;
85
+ const auxiliaryConnections = new Set();
74
86
  let handoffCount = 0;
75
87
  let handoffAttemptCount = 0;
76
88
  let socketUpgradeCount = 0;
77
89
  let secondConnectionRejectCount = 0;
78
90
  let candidateRejectCount = 0;
91
+ let auxiliaryConnectionCount = 0;
92
+ let auxiliaryRejectCount = 0;
93
+ let auxiliaryMutationRejectCount = 0;
94
+ let auxiliaryUnknownResponseCount = 0;
95
+ let auxiliaryIdleTimeoutCount = 0;
96
+ let auxiliaryMessageCount = 0;
97
+ let auxiliaryNotificationCount = 0;
79
98
  let shuttingDown = false;
80
99
  const reportPath = process.env.CODEX_TERMINAL_BRIDGE_REPORT;
81
100
  let initializeReceived = false;
@@ -110,10 +129,12 @@ async function main() {
110
129
  };
111
130
  const READ_ONLY_DISCOVERY = new Set([
112
131
  'account/read',
132
+ 'account/bedrock/checkGovCloudRequirements',
133
+ 'config/read',
134
+ 'configRequirements/read',
113
135
  'thread/list',
114
136
  'thread/read',
115
137
  'model/list',
116
- 'configRequirements/read',
117
138
  'collaborationMode/list',
118
139
  'hooks/list',
119
140
  'thread/turns/list',
@@ -131,6 +152,8 @@ async function main() {
131
152
  'initialize',
132
153
  'initialized',
133
154
  'account/read',
155
+ 'account/bedrock/checkGovCloudRequirements',
156
+ 'config/read',
134
157
  'thread/list',
135
158
  'thread/read',
136
159
  'model/list',
@@ -192,6 +215,10 @@ async function main() {
192
215
  rejectCandidate(generation.connection, 1011, 'candidate_monitor_failed');
193
216
  return;
194
217
  }
218
+ if (generation.connection?.isAuxiliary) {
219
+ rejectAuxiliary(generation.connection, 'auxiliary_monitor_failed');
220
+ return;
221
+ }
195
222
  monitorExitCode = code;
196
223
  void cleanup().finally(() => process.exit(code));
197
224
  };
@@ -270,6 +297,10 @@ async function main() {
270
297
  rejectCandidate(connection, 1013, reason);
271
298
  return;
272
299
  }
300
+ if (connection?.isAuxiliary) {
301
+ rejectAuxiliary(connection, reason);
302
+ return;
303
+ }
273
304
  if (connection) connection.retiring = true;
274
305
  cleanupGeneration(generation);
275
306
  connection?.socket.close(1013, reason);
@@ -310,7 +341,7 @@ async function main() {
310
341
  ),
311
342
  new Promise((resolve) => setTimeout(resolve, 500)),
312
343
  ]);
313
- for (const connection of [activeConnection, pendingCandidate]) {
344
+ for (const connection of [activeConnection, pendingCandidate, ...auxiliaryConnections]) {
314
345
  if (connection?.socket.readyState === WebSocket.OPEN)
315
346
  connection.socket.close(1001, 'bridge stopped');
316
347
  connection?.socket.terminate();
@@ -350,6 +381,13 @@ async function main() {
350
381
  socket_upgrade_count: socketUpgradeCount,
351
382
  second_connection_reject_count: secondConnectionRejectCount,
352
383
  candidate_reject_count: candidateRejectCount,
384
+ auxiliary_connection_count: auxiliaryConnectionCount,
385
+ auxiliary_reject_count: auxiliaryRejectCount,
386
+ auxiliary_mutation_reject_count: auxiliaryMutationRejectCount,
387
+ auxiliary_unknown_response_count: auxiliaryUnknownResponseCount,
388
+ auxiliary_idle_timeout_count: auxiliaryIdleTimeoutCount,
389
+ auxiliary_message_count: auxiliaryMessageCount,
390
+ auxiliary_notification_count: auxiliaryNotificationCount,
353
391
  cleanup_initiator: cleanupInitiator,
354
392
  last_socket_close_code: lastSocketCloseCode,
355
393
  terminal_alive_at_socket_close: terminalAliveAtSocketClose,
@@ -410,6 +448,24 @@ async function main() {
410
448
  connection.initialized === true &&
411
449
  connection.initializeAck === true &&
412
450
  (connection.socket.readyState === WebSocket.OPEN || connection.discoveryClosed === true);
451
+ const auxiliaryIdleTimeoutMs = (() => {
452
+ const value = Number.parseInt(process.env.CODEX_AUXILIARY_IDLE_TIMEOUT_MS ?? '', 10);
453
+ return Number.isSafeInteger(value) && value >= 1_000 && value <= 300_000
454
+ ? value
455
+ : DEFAULT_AUXILIARY_IDLE_TIMEOUT_MS;
456
+ })();
457
+ const rejectAuxiliary = (connection, reason = 'auxiliary_rejected') => {
458
+ if (!connection || connection.retiring || !connection.isAuxiliary) return;
459
+ connection.retiring = true;
460
+ if (connection.auxiliaryTimer !== null) clearTimeout(connection.auxiliaryTimer);
461
+ auxiliaryConnections.delete(connection);
462
+ auxiliaryRejectCount += 1;
463
+ if (reason === 'auxiliary_idle_timeout') auxiliaryIdleTimeoutCount += 1;
464
+ if (connection.socket.readyState === WebSocket.OPEN)
465
+ connection.socket.close(1008, reason);
466
+ else connection.socket.terminate();
467
+ cleanupGeneration(connection.generation);
468
+ };
413
469
  const rejectCandidate = (connection, statusCode = 1008, reason = 'handoff_rejected') => {
414
470
  if (!connection || connection.retiring) return;
415
471
  if (connection?.candidateTimer) clearTimeout(connection.candidateTimer);
@@ -474,7 +530,12 @@ async function main() {
474
530
  let parsed;
475
531
  try {
476
532
  parsed = JSON.parse(line.toString('utf8'));
477
- if (connection.initializeId !== null && parsed.id === connection.initializeId) {
533
+ const isInitializeResponse =
534
+ connection.initializeId !== null &&
535
+ parsed.id === connection.initializeId &&
536
+ connection.initializeResponseConsumed === false;
537
+ if (isInitializeResponse) {
538
+ connection.initializeResponseConsumed = true;
478
539
  initializeResponseSeen = true;
479
540
  initializeErrorCode = parsed.error?.code ?? null;
480
541
  if (parsed.error === undefined && parsed.result !== undefined) {
@@ -501,6 +562,25 @@ async function main() {
501
562
  const isJsonRpcResponse =
502
563
  Object.prototype.hasOwnProperty.call(parsed, 'result') ||
503
564
  Object.prototype.hasOwnProperty.call(parsed, 'error');
565
+ if (connection.isAuxiliary && parsed.method !== undefined && parsed.id === undefined) {
566
+ auxiliaryNotificationCount += 1;
567
+ connection.auxiliaryNotificationCount += 1;
568
+ if (connection.auxiliaryNotificationCount > MAX_AUXILIARY_NOTIFICATIONS) {
569
+ rejectAuxiliary(connection, 'auxiliary_notification_limit');
570
+ }
571
+ return;
572
+ }
573
+ if (
574
+ connection.isAuxiliary &&
575
+ (parsed.method !== undefined ||
576
+ (isJsonRpcResponse &&
577
+ !isInitializeResponse &&
578
+ !connection.pendingRequests.has(parsed.id)))
579
+ ) {
580
+ auxiliaryUnknownResponseCount += 1;
581
+ rejectAuxiliary(connection, 'auxiliary_response_rejected');
582
+ return;
583
+ }
504
584
  if (
505
585
  isJsonRpcResponse &&
506
586
  parsed.id !== undefined &&
@@ -540,6 +620,11 @@ async function main() {
540
620
  } catch {
541
621
  parsed = null;
542
622
  }
623
+ if (connection.isAuxiliary && parsed === null) {
624
+ auxiliaryUnknownResponseCount += 1;
625
+ rejectAuxiliary(connection, 'auxiliary_response_rejected');
626
+ return;
627
+ }
543
628
  if (connection.socket.readyState !== WebSocket.OPEN) return;
544
629
  if (connection.socket.bufferedAmount + line.length > MAX_QUEUED_BYTES) {
545
630
  abortGeneration(generation, 'bridge_queue_limit');
@@ -555,18 +640,29 @@ async function main() {
555
640
  const parsed = JSON.parse(frame.toString('utf8'));
556
641
  if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
557
642
  throw new Error('bridge_message_not_object');
643
+ if (
644
+ connection.isAuxiliary &&
645
+ (typeof parsed.method !== 'string' ||
646
+ Object.prototype.hasOwnProperty.call(parsed, 'result') ||
647
+ Object.prototype.hasOwnProperty.call(parsed, 'error'))
648
+ ) {
649
+ auxiliaryMutationRejectCount += 1;
650
+ rejectAuxiliary(connection, 'auxiliary_client_frame_rejected');
651
+ return;
652
+ }
558
653
  const normalizedFrame = Buffer.from(JSON.stringify(parsed), 'utf8');
654
+ const wasInitialized = connection.initialized;
559
655
  if (!connection.initialized) {
560
656
  const initialize = parseInitialize(normalizedFrame.toString('utf8'));
561
657
  if (
562
- connection.isCandidate &&
658
+ (connection.isCandidate || connection.isAuxiliary) &&
563
659
  !sameClient(activeConnection?.clientInfo, initialize.params.clientInfo)
564
660
  )
565
661
  throw new Error('handoff_client_mismatch');
566
662
  connection.initialized = true;
567
663
  connection.initializeId = initialize.id;
568
664
  connection.clientInfo = initialize.params.clientInfo;
569
- if (!connection.isCandidate) {
665
+ if (!connection.isCandidate && !connection.isAuxiliary) {
570
666
  initializeReceived = true;
571
667
  experimentalApi = initialize.params.capabilities.experimentalApi === true;
572
668
  }
@@ -575,8 +671,30 @@ async function main() {
575
671
  connection.generation = spawnMonitor();
576
672
  connection.generation.connection = connection;
577
673
  }
674
+ if (connection.isAuxiliary && connection.generation === null) {
675
+ connection.generation = spawnMonitor();
676
+ connection.generation.connection = connection;
677
+ }
578
678
  }
579
- if (parsed.method === 'turn/start') turnStartCount += 1;
679
+ if (connection.isAuxiliary) {
680
+ auxiliaryMessageCount += 1;
681
+ connection.auxiliaryMessageCount += 1;
682
+ if (connection.auxiliaryMessageCount > MAX_AUXILIARY_MESSAGES) {
683
+ rejectAuxiliary(connection, 'auxiliary_message_limit');
684
+ return;
685
+ }
686
+ if (connection.auxiliaryTimer !== null) clearTimeout(connection.auxiliaryTimer);
687
+ connection.auxiliaryTimer = setTimeout(
688
+ () => rejectAuxiliary(connection, 'auxiliary_idle_timeout'),
689
+ auxiliaryIdleTimeoutMs,
690
+ );
691
+ }
692
+ if (connection.isAuxiliary && wasInitialized && parsed.method === 'initialize') {
693
+ auxiliaryMutationRejectCount += 1;
694
+ rejectAuxiliary(connection, 'auxiliary_duplicate_initialize');
695
+ return;
696
+ }
697
+ if (parsed.method === 'turn/start' && !connection.isAuxiliary) turnStartCount += 1;
580
698
  if (typeof parsed.method === 'string') {
581
699
  if (DIAGNOSTIC_METHODS.has(parsed.method)) {
582
700
  if (observedMethods.size < MAX_DIAGNOSTIC_ENTRIES) observedMethods.add(parsed.method);
@@ -585,7 +703,33 @@ async function main() {
585
703
  }
586
704
  if (parsed.method === 'thread/resume') threadResumeCount += 1;
587
705
  if (parsed.method === 'initialize' || parsed.method === 'initialized') {
706
+ if (connection.isAuxiliary && parsed.method === 'initialized') {
707
+ if (connection.initializedNotificationSeen) {
708
+ auxiliaryMutationRejectCount += 1;
709
+ rejectAuxiliary(connection, 'auxiliary_duplicate_initialized');
710
+ return;
711
+ }
712
+ connection.initializedNotificationSeen = true;
713
+ }
588
714
  // The notification is part of the handshake and does not affect startup eligibility.
715
+ } else if (connection.isAuxiliary) {
716
+ if (!READ_ONLY_DISCOVERY.has(parsed.method)) {
717
+ auxiliaryMutationRejectCount += 1;
718
+ rejectAuxiliary(connection, 'auxiliary_mutation_rejected');
719
+ return;
720
+ }
721
+ const validRequestId =
722
+ (typeof parsed.id === 'string' && parsed.id.length > 0 && parsed.id.length <= 128) ||
723
+ (Number.isSafeInteger(parsed.id) && parsed.id >= 0);
724
+ if (
725
+ !validRequestId ||
726
+ connection.pendingRequests.has(parsed.id) ||
727
+ connection.pendingRequests.size >= MAX_AUXILIARY_IN_FLIGHT
728
+ ) {
729
+ rejectAuxiliary(connection, 'auxiliary_request_limit');
730
+ return;
731
+ }
732
+ connection.pendingRequests.set(parsed.id, parsed.method);
589
733
  } else if (READ_ONLY_DISCOVERY.has(parsed.method)) {
590
734
  const validRequestId =
591
735
  (typeof parsed.id === 'string' && parsed.id.length > 0 && parsed.id.length <= 128) ||
@@ -627,18 +771,22 @@ async function main() {
627
771
  }
628
772
  } catch (error) {
629
773
  if (connection.isCandidate) rejectCandidate(connection, 1008, safeError(error));
774
+ else if (connection.isAuxiliary) rejectAuxiliary(connection, safeError(error));
630
775
  else connection.socket.close(1008, safeError(error));
631
776
  }
632
777
  };
633
- const attachConnection = (webSocket, isCandidate) => {
778
+ const attachConnection = (webSocket, isCandidate, isAuxiliary = false) => {
634
779
  const connection = {
635
780
  socket: webSocket,
636
- generation: isCandidate ? null : initialGeneration,
781
+ generation: isCandidate || isAuxiliary ? null : initialGeneration,
637
782
  isCandidate,
638
- isActive: !isCandidate,
783
+ isAuxiliary,
784
+ isActive: !isCandidate && !isAuxiliary,
639
785
  retiring: false,
640
786
  initialized: false,
641
787
  initializeId: null,
788
+ initializeResponseConsumed: false,
789
+ initializedNotificationSeen: false,
642
790
  initializeAck: false,
643
791
  clientInfo: null,
644
792
  pendingRequests: new Map(),
@@ -651,15 +799,27 @@ async function main() {
651
799
  handoffComplete: false,
652
800
  discoveryClosed: false,
653
801
  candidateTimer: null,
802
+ auxiliaryTimer: null,
803
+ auxiliaryMessageCount: 0,
804
+ auxiliaryNotificationCount: 0,
654
805
  };
655
- if (!isCandidate) {
806
+ if (!isCandidate && !isAuxiliary) {
656
807
  activeConnection = connection;
657
808
  initialGeneration.connection = connection;
658
809
  }
810
+ if (isAuxiliary) {
811
+ auxiliaryConnections.add(connection);
812
+ auxiliaryConnectionCount += 1;
813
+ connection.auxiliaryTimer = setTimeout(
814
+ () => rejectAuxiliary(connection, 'auxiliary_idle_timeout'),
815
+ auxiliaryIdleTimeoutMs,
816
+ );
817
+ }
659
818
  webSocket.on('message', (data, isBinary) => handleClientMessage(connection, data, isBinary));
660
819
  webSocket.on('error', () => {
661
820
  if (connection.retiring || shuttingDown) return;
662
821
  if (connection.isCandidate) rejectCandidate(connection, 1011, 'candidate_socket_error');
822
+ else if (connection.isAuxiliary) rejectAuxiliary(connection, 'auxiliary_socket_error');
663
823
  else {
664
824
  cleanupInitiator = 'socket_error';
665
825
  recordLifecycle('socket_error');
@@ -672,6 +832,12 @@ async function main() {
672
832
  lastSocketCloseCode = code;
673
833
  terminalAliveAtSocketClose = terminal?.exitCode === null;
674
834
  recordLifecycle('socket_close');
835
+ if (connection.isAuxiliary) {
836
+ if (connection.auxiliaryTimer !== null) clearTimeout(connection.auxiliaryTimer);
837
+ auxiliaryConnections.delete(connection);
838
+ cleanupGeneration(connection.generation);
839
+ return;
840
+ }
675
841
  if (connection.isCandidate) {
676
842
  rejectCandidate(connection);
677
843
  return;
@@ -725,14 +891,23 @@ async function main() {
725
891
  closeSocket(socket, 403, 'origin_rejected');
726
892
  return;
727
893
  }
728
- const isCandidate = activeConnection !== null;
729
- if (isCandidate && !canHandoff(activeConnection)) {
894
+ const isCandidate = activeConnection !== null && canHandoff(activeConnection);
895
+ const isAuxiliary =
896
+ activeConnection !== null &&
897
+ !isCandidate &&
898
+ activeConnection.initialized === true &&
899
+ activeConnection.startedOperation === true;
900
+ if (
901
+ activeConnection !== null &&
902
+ !isCandidate &&
903
+ (!isAuxiliary || auxiliaryConnections.size >= MAX_AUXILIARY_CONNECTIONS)
904
+ ) {
730
905
  secondConnectionRejectCount += 1;
731
906
  closeSocket(socket, 409, 'second_connection_rejected');
732
907
  return;
733
908
  }
734
909
  webSocketServer.handleUpgrade(request, socket, head, (webSocket) => {
735
- const connection = attachConnection(webSocket, isCandidate);
910
+ const connection = attachConnection(webSocket, isCandidate, isAuxiliary);
736
911
  if (isCandidate) {
737
912
  pendingCandidate = connection;
738
913
  connection.candidateTimer = setTimeout(
@@ -0,0 +1,506 @@
1
+ // @ts-check
2
+
3
+ import {
4
+ chmod,
5
+ cp,
6
+ lstat,
7
+ mkdir,
8
+ mkdtemp,
9
+ readdir,
10
+ realpath,
11
+ rm,
12
+ writeFile,
13
+ } from 'node:fs/promises';
14
+ import { dirname, join, resolve } from 'node:path';
15
+ import { tmpdir } from 'node:os';
16
+ import { spawn } from 'node:child_process';
17
+
18
+ const MAX_OUTPUT_BYTES = 96 * 1024;
19
+ const PROBE_TIMEOUT_MS = 30_000;
20
+ const MAX_PLUGIN_ASSET_BYTES = 4 * 1024 * 1024;
21
+ const MAX_PLUGIN_TOTAL_BYTES = 16 * 1024 * 1024;
22
+ const HOST_MAJOR = 2;
23
+ const HOST_MINOR = 1;
24
+ const QUALIFIED_VERSIONS = new Set([
25
+ '2.1.294',
26
+ '2.1.295',
27
+ '2.1.296',
28
+ '2.1.299',
29
+ '2.1.300',
30
+ '2.1.301',
31
+ '2.1.302',
32
+ '2.1.303',
33
+ ]);
34
+ const REQUIRED_FLAGS = ['--settings', '--mcp-config', '--allowedTools', '--plugin-dir'];
35
+ const REQUIRED_INVENTORY = [
36
+ 'prompt.submit',
37
+ 'tool.call',
38
+ 'session.end',
39
+ 'turn.complete',
40
+ 'session.compact',
41
+ '$.mcp.call',
42
+ '$.process.run',
43
+ '$.session.id',
44
+ '$.session.messages',
45
+ '$.session.usage',
46
+ '$.session.append',
47
+ '$.session.compact',
48
+ '$.store.get',
49
+ '$.store.set',
50
+ '$.store.delete',
51
+ '$.store.keys',
52
+ '$.ui.log',
53
+ '$.ui.toast',
54
+ ];
55
+ const ALLOWED_PLUGIN_FILES = Object.freeze([
56
+ '.claude-plugin/plugin.json',
57
+ 'hooks/hooks.json',
58
+ 'hooks/context-compaction.ts',
59
+ 'hooks/provider-window.js',
60
+ 'hooks/state-fitting.mjs',
61
+ 'hooks/state-fitting.d.mts',
62
+ 'hooks/native-host-contract.test.ts',
63
+ 'hooks/native-host-contract-runtime.ts',
64
+ ]);
65
+
66
+ /** @param {string} executable */
67
+ function safePath(executable) {
68
+ return [...new Set([dirname(executable), dirname(process.execPath), '/usr/bin', '/bin'])].join(
69
+ ':',
70
+ );
71
+ }
72
+
73
+ /** @param {string} executable @returns {Promise<string>} */
74
+ async function readNativeVersion(executable) {
75
+ const root = await mkdtemp(join(tmpdir(), 'aibrain-claude-version-'));
76
+ const cwd = join(root, 'cwd');
77
+ const home = join(root, 'home');
78
+ const config = join(root, 'config');
79
+ await mkdir(cwd, { recursive: true, mode: 0o700 });
80
+ try {
81
+ /** @type {NodeJS.ProcessEnv} */
82
+ const environment = {
83
+ PATH: safePath(executable),
84
+ HOME: home,
85
+ CLAUDE_CONFIG_DIR: config,
86
+ CLAUDE_COMPAT_CWD: cwd,
87
+ NO_COLOR: '1',
88
+ CI: '1',
89
+ CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
90
+ };
91
+ await mkdir(home, { recursive: true, mode: 0o700 });
92
+ await mkdir(config, { recursive: true, mode: 0o700 });
93
+ const result = await runBounded(executable, ['--version'], environment, {
94
+ timeoutMs: 10_000,
95
+ maxOutputBytes: 2_048,
96
+ });
97
+ if (result.code !== 0 || result.overflow || result.timedOut) return '';
98
+ return result.stdout.split(/\r?\n/u, 1)[0]?.trim() ?? '';
99
+ } finally {
100
+ try {
101
+ await rm(root, { recursive: true, force: true });
102
+ } catch {
103
+ throw new Error('probe_cleanup_failed');
104
+ }
105
+ }
106
+ }
107
+
108
+ /** @typedef {'qualified'|'host_checked'|'rejected'} ClaudeCompatibilityStatus */
109
+
110
+ /** @typedef {object} ClaudeCompatibility
111
+ * @property {ClaudeCompatibilityStatus} status
112
+ * @property {string|null} executable
113
+ * @property {string|null} version
114
+ * @property {string} reason
115
+ * @property {string[]} [checks]
116
+ */
117
+
118
+ /** @param {string} output */
119
+ export function parseClaudeStableVersion(output) {
120
+ const match = output
121
+ .trim()
122
+ .match(
123
+ /^(?:(?:claude(?:\s+code)?)\s+)?(0|[1-9]\d{0,2})\.(0|[1-9]\d{0,2})\.(0|[1-9]\d{0,2})(-[A-Za-z0-9.-]+)?(?:\s+\(Claude\s+Code\))?$/iu,
124
+ );
125
+ if (!match) return null;
126
+ return {
127
+ major: Number(match[1]),
128
+ minor: Number(match[2]),
129
+ patch: Number(match[3]),
130
+ prerelease: match[4] !== undefined,
131
+ text: `${match[1]}.${match[2]}.${match[3]}`,
132
+ };
133
+ }
134
+
135
+ /** @param {string} output */
136
+ function parseVersion(output) {
137
+ return parseClaudeStableVersion(output) ?? null;
138
+ }
139
+
140
+ /** @param {string} executable @param {string[]} args @param {NodeJS.ProcessEnv} environment @param {{timeoutMs?:number,maxOutputBytes?:number}} [limits] */
141
+ function runBounded(executable, args, environment, limits = {}) {
142
+ const timeoutMs = limits.timeoutMs ?? PROBE_TIMEOUT_MS;
143
+ const maxOutputBytes = limits.maxOutputBytes ?? MAX_OUTPUT_BYTES;
144
+ return new Promise((resolvePromise) => {
145
+ const child = spawn(executable, args, {
146
+ cwd: environment.CLAUDE_COMPAT_CWD,
147
+ env: environment,
148
+ stdio: ['ignore', 'pipe', 'pipe'],
149
+ detached: process.platform !== 'win32',
150
+ });
151
+ let stdout = '';
152
+ let stderr = '';
153
+ let bytes = 0;
154
+ let overflow = false;
155
+ let settled = false;
156
+ let timedOut = false;
157
+ const timer = setTimeout(() => {
158
+ timedOut = true;
159
+ if (process.platform !== 'win32' && child.pid !== undefined) {
160
+ try {
161
+ process.kill(-child.pid, 'SIGKILL');
162
+ } catch {
163
+ child.kill('SIGKILL');
164
+ }
165
+ } else child.kill('SIGKILL');
166
+ }, timeoutMs);
167
+ /** @param {string} target @param {Buffer} chunk */
168
+ const append = (target, chunk) => {
169
+ bytes += chunk.byteLength;
170
+ if (bytes > maxOutputBytes) {
171
+ overflow = true;
172
+ return target;
173
+ }
174
+ return `${target}${chunk.toString('utf8')}`;
175
+ };
176
+ /** @param {number|null} code */
177
+ const finish = (code) => {
178
+ if (settled) return;
179
+ settled = true;
180
+ clearTimeout(timer);
181
+ resolvePromise({ code, stdout, stderr, overflow, timedOut });
182
+ };
183
+ child.stdout.on('data', (chunk) => {
184
+ stdout = append(stdout, chunk);
185
+ });
186
+ child.stderr.on('data', (chunk) => {
187
+ stderr = append(stderr, chunk);
188
+ });
189
+ child.once('error', () => finish(null));
190
+ child.once('close', (code) => finish(code));
191
+ });
192
+ }
193
+
194
+ /** @param {string} root @param {string} relativePath */
195
+ async function assertNoSymlinkPath(root, relativePath) {
196
+ let current = resolve(root);
197
+ const parts = relativePath.split('/');
198
+ for (const part of parts) {
199
+ current = join(current, part);
200
+ const info = await lstat(current);
201
+ if (info.isSymbolicLink()) throw new Error('plugin_asset_symlink_rejected');
202
+ }
203
+ }
204
+
205
+ /** @param {string} source @param {string} destination */
206
+ async function copyAllowlistedPlugin(source, destination) {
207
+ await mkdir(destination, { recursive: true, mode: 0o700 });
208
+ let totalBytes = 0;
209
+ for (const file of ALLOWED_PLUGIN_FILES) {
210
+ const input = join(source, file);
211
+ await assertNoSymlinkPath(source, file);
212
+ const info = await lstat(input);
213
+ if (!info.isFile() || info.isSymbolicLink()) throw new Error('plugin_asset_invalid');
214
+ const uid = process.getuid?.();
215
+ if (uid !== undefined && info.uid !== uid) throw new Error('plugin_asset_owner_mismatch');
216
+ if (info.size > MAX_PLUGIN_ASSET_BYTES) throw new Error('plugin_asset_too_large');
217
+ totalBytes += info.size;
218
+ if (totalBytes > MAX_PLUGIN_TOTAL_BYTES) throw new Error('plugin_assets_too_large');
219
+ const output = join(destination, file);
220
+ await mkdir(dirname(output), { recursive: true, mode: 0o700 });
221
+ await cp(input, output, { force: true, errorOnExist: true, verbatimSymlinks: true });
222
+ await chmod(output, 0o600);
223
+ }
224
+ }
225
+
226
+ /** @param {string} directory */
227
+ async function assertEmptyDirectory(directory) {
228
+ const entries = await readdir(directory);
229
+ if (entries.length !== 0) throw new Error('probe_directory_not_empty');
230
+ }
231
+
232
+ /** @param {unknown} value @returns {value is Record<string, unknown>} */
233
+ function record(value) {
234
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
235
+ }
236
+
237
+ /** @param {unknown} value @returns {value is string[]} */
238
+ function stringArray(value) {
239
+ return Array.isArray(value) && value.every((item) => typeof item === 'string');
240
+ }
241
+
242
+ /** @param {string} executable @param {string} pluginRoot @param {{timeoutMs?:number,maxOutputBytes?:number}} [limits] */
243
+ async function runHostProbe(executable, pluginRoot, limits = {}) {
244
+ const root = await mkdtemp(join(tmpdir(), 'aibrain-claude-host-'));
245
+ const home = join(root, 'home');
246
+ const config = join(root, 'config');
247
+ const cwd = join(root, 'cwd');
248
+ const copiedPlugin = join(root, 'plugin');
249
+ const contractPlugin = join(root, 'contract-plugin');
250
+ await Promise.all([
251
+ mkdir(home, { recursive: true, mode: 0o700 }),
252
+ mkdir(config, { recursive: true, mode: 0o700 }),
253
+ mkdir(cwd, { recursive: true, mode: 0o700 }),
254
+ ]);
255
+ try {
256
+ await copyAllowlistedPlugin(pluginRoot, copiedPlugin);
257
+ await copyAllowlistedPlugin(pluginRoot, contractPlugin);
258
+ await writeFile(
259
+ join(contractPlugin, 'hooks', 'hooks.json'),
260
+ JSON.stringify({ modules: ['./native-host-contract-runtime.ts'] }),
261
+ { mode: 0o600 },
262
+ );
263
+ /** @type {NodeJS.ProcessEnv} */
264
+ const environment = {
265
+ PATH: safePath(executable),
266
+ HOME: home,
267
+ CLAUDE_CONFIG_DIR: config,
268
+ CLAUDE_COMPAT_CWD: cwd,
269
+ CLAUDE_CODE_ENABLE_FUNCTION_HOOKS: '1',
270
+ AIBRAIN_NATIVE_CONTRACT: '1',
271
+ CLAUDE_CODE_DISABLE_AUTO_MEMORY: '1',
272
+ CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
273
+ NO_COLOR: '1',
274
+ CI: '1',
275
+ };
276
+ const help = await runBounded(executable, ['--help'], environment, limits);
277
+ if (
278
+ help.code !== 0 ||
279
+ help.overflow ||
280
+ REQUIRED_FLAGS.some((flag) => !help.stdout.includes(flag))
281
+ )
282
+ return { ok: false, reason: 'launch_flags_missing' };
283
+ const validate = await runBounded(
284
+ executable,
285
+ ['plugin', 'validate', '--strict', '--json', copiedPlugin],
286
+ environment,
287
+ limits,
288
+ );
289
+ if (validate.code !== 0 || validate.overflow)
290
+ return { ok: false, reason: 'plugin_validation_failed' };
291
+ /** @type {Record<string, unknown>} */
292
+ let report;
293
+ try {
294
+ const parsed = JSON.parse(validate.stdout);
295
+ if (!record(parsed)) return { ok: false, reason: 'plugin_validation_malformed' };
296
+ report = parsed;
297
+ } catch {
298
+ return { ok: false, reason: 'plugin_validation_malformed' };
299
+ }
300
+ if (report.success !== true || report.strict !== true)
301
+ return { ok: false, reason: 'plugin_validation_failed' };
302
+ if (
303
+ !record(report.manifest) ||
304
+ !stringArray(report.manifest.errors) ||
305
+ !stringArray(report.manifest.warnings)
306
+ )
307
+ return { ok: false, reason: 'plugin_validation_malformed' };
308
+ if (
309
+ !Array.isArray(report.contents) ||
310
+ report.contents.length !== 1 ||
311
+ !report.contents.every(record)
312
+ )
313
+ return { ok: false, reason: 'plugin_validation_malformed' };
314
+ const contents = report.contents;
315
+ if (
316
+ !contents.every(
317
+ (item) => stringArray(item.errors) && stringArray(item.warnings) && stringArray(item.notes),
318
+ )
319
+ )
320
+ return { ok: false, reason: 'plugin_validation_malformed' };
321
+ const manifest = report.manifest;
322
+ const errors = [
323
+ ...(Array.isArray(manifest.errors) ? manifest.errors : []),
324
+ ...(Array.isArray(manifest.warnings) ? manifest.warnings : []),
325
+ ...contents.flatMap((item) => [
326
+ ...(Array.isArray(item.errors) ? item.errors : []),
327
+ ...(Array.isArray(item.warnings) ? item.warnings : []),
328
+ ]),
329
+ ];
330
+ if (errors.length > 0) return { ok: false, reason: 'plugin_validation_failed' };
331
+ const notes = contents.flatMap((item) => item.notes);
332
+ const hookNote = notes.find((note) => typeof note === 'string' && note.includes(' hooks: '));
333
+ const callNote = notes.find((note) => typeof note === 'string' && note.includes(' calls: '));
334
+ const hooks = new Set(
335
+ typeof hookNote === 'string'
336
+ ? hookNote
337
+ .split(' hooks: ', 2)[1]
338
+ .split(', ')
339
+ .map((item) => item.trim())
340
+ : [],
341
+ );
342
+ const calls = new Set(
343
+ typeof callNote === 'string'
344
+ ? callNote
345
+ .split(' calls: ', 2)[1]
346
+ .replace(/\s+\(via[^)]*\)/gu, '')
347
+ .split(', ')
348
+ .map((item) => item.trim())
349
+ : [],
350
+ );
351
+ const inventory = new Set([...hooks, ...calls]);
352
+ if (inventory.size === 0 || REQUIRED_INVENTORY.some((item) => !inventory.has(item)))
353
+ return { ok: false, reason: 'host_inventory_incomplete' };
354
+ const test = await runBounded(
355
+ executable,
356
+ ['plugin', 'test', contractPlugin],
357
+ environment,
358
+ limits,
359
+ );
360
+ const passMatch = (test.stdout + test.stderr).match(/(?:^|\s)(\d+)\s+pass(?:ed)?(?:\s|$)/iu);
361
+ const failMatch = (test.stdout + test.stderr).match(/(?:^|\s)(\d+)\s+fail(?:ed)?(?:\s|$)/iu);
362
+ if (test.timedOut) return { ok: false, reason: 'native_plugin_tests_failed' };
363
+ if (test.code !== 0) return { ok: false, reason: 'native_plugin_tests_failed' };
364
+ if (test.overflow) return { ok: false, reason: 'native_plugin_tests_failed' };
365
+ if (passMatch === null || Number(passMatch[1]) < 1)
366
+ return { ok: false, reason: 'native_plugin_tests_failed' };
367
+ if (failMatch === null || Number(failMatch[1]) !== 0)
368
+ return { ok: false, reason: 'native_plugin_tests_failed' };
369
+ if (
370
+ !test.stdout.includes(
371
+ 'AI Brain enabled host contract invokes usage and native compaction through the dispatcher',
372
+ )
373
+ )
374
+ return { ok: false, reason: 'native_plugin_tests_failed' };
375
+ await assertEmptyDirectory(cwd);
376
+ return {
377
+ ok: true,
378
+ checks: [
379
+ 'launch_flags',
380
+ 'plugin_validate_strict',
381
+ 'native_plugin_test',
382
+ 'isolated_filesystem',
383
+ ],
384
+ };
385
+ } catch (error) {
386
+ const reason = error instanceof Error ? error.message : '';
387
+ const allowedReasons = new Set([
388
+ 'plugin_asset_invalid',
389
+ 'plugin_asset_symlink_rejected',
390
+ 'plugin_asset_owner_mismatch',
391
+ 'plugin_asset_too_large',
392
+ 'plugin_assets_too_large',
393
+ 'probe_directory_not_empty',
394
+ ]);
395
+ return { ok: false, reason: allowedReasons.has(reason) ? reason : 'host_probe_failed' };
396
+ } finally {
397
+ try {
398
+ await rm(root, { recursive: true, force: true });
399
+ } catch {
400
+ throw new Error('probe_cleanup_failed');
401
+ }
402
+ }
403
+ }
404
+
405
+ /** @param {string} executable @param {string} packageRoot */
406
+ /** @param {string} executable @param {string} packageRoot @returns {Promise<ClaudeCompatibility>} */
407
+ export async function resolveClaudeCompatibility(executable, packageRoot) {
408
+ let pinnedExecutable;
409
+ try {
410
+ pinnedExecutable = await realpath(executable);
411
+ } catch {
412
+ return {
413
+ status: 'rejected',
414
+ executable: null,
415
+ version: null,
416
+ reason: 'executable_unavailable',
417
+ };
418
+ }
419
+ let raw;
420
+ try {
421
+ raw = await readNativeVersion(pinnedExecutable);
422
+ } catch {
423
+ return {
424
+ status: 'rejected',
425
+ executable: pinnedExecutable,
426
+ version: null,
427
+ reason: 'version_probe_cleanup_failed',
428
+ };
429
+ }
430
+ const version = parseVersion(raw);
431
+ if (!version)
432
+ return {
433
+ status: 'rejected',
434
+ executable: pinnedExecutable,
435
+ version: null,
436
+ reason: 'version_unavailable',
437
+ };
438
+ if (version.prerelease)
439
+ return {
440
+ status: 'rejected',
441
+ executable: pinnedExecutable,
442
+ version: version.text,
443
+ reason: 'prerelease',
444
+ };
445
+ if (version.major !== HOST_MAJOR || version.minor !== HOST_MINOR) {
446
+ return {
447
+ status: 'rejected',
448
+ executable: pinnedExecutable,
449
+ version: version.text,
450
+ reason: 'major_minor_unsupported',
451
+ };
452
+ }
453
+ if (QUALIFIED_VERSIONS.has(version.text))
454
+ return {
455
+ status: 'qualified',
456
+ executable: pinnedExecutable,
457
+ version: version.text,
458
+ reason: 'known_stable',
459
+ };
460
+ if (version.patch < 303)
461
+ return {
462
+ status: 'rejected',
463
+ executable: pinnedExecutable,
464
+ version: version.text,
465
+ reason: 'unsupported_stable_patch',
466
+ };
467
+ let probe;
468
+ try {
469
+ probe = await runHostProbe(pinnedExecutable, join(resolve(packageRoot), 'assets', 'claude'));
470
+ } catch {
471
+ return {
472
+ status: 'rejected',
473
+ executable: pinnedExecutable,
474
+ version: version.text,
475
+ reason: 'host_probe_cleanup_failed',
476
+ };
477
+ }
478
+ if (!probe.ok)
479
+ return {
480
+ status: 'rejected',
481
+ executable: pinnedExecutable,
482
+ version: version.text,
483
+ reason: probe.reason ?? 'host_probe_failed',
484
+ };
485
+ return {
486
+ status: 'host_checked',
487
+ executable: pinnedExecutable,
488
+ version: version.text,
489
+ reason: 'native_host_contract',
490
+ ...(probe.checks === undefined ? {} : { checks: probe.checks }),
491
+ };
492
+ }
493
+
494
+ /** @param {ClaudeCompatibility} decision */
495
+ export function claudeCompatibilityError(decision) {
496
+ const detected = decision.version ?? 'unavailable';
497
+ return `claude_version_unverified: Claude Code detected version ${detected}; reason ${decision.reason}; run aibrain doctor --json or install a compatible stable native version.`;
498
+ }
499
+
500
+ export const claudeCompatibilityInternals = Object.freeze({
501
+ REQUIRED_FLAGS,
502
+ REQUIRED_INVENTORY,
503
+ ALLOWED_PLUGIN_FILES,
504
+ parseVersion,
505
+ runHostProbe,
506
+ });
package/dist/cli.mjs CHANGED
@@ -15,21 +15,16 @@ import {
15
15
  stateRoot,
16
16
  tokenDigest,
17
17
  } from './state.mjs';
18
- import {
19
- isTestedNativeVersion,
20
- nativeVersion,
21
- nativeVersionDiagnostic,
22
- platformCheck,
23
- resolveNative,
24
- } from './platform.mjs';
18
+ import { nativeVersion, platformCheck, resolveNative } from './platform.mjs';
25
19
  import { launchClaude, launchCodex } from './launch.mjs';
20
+ import { claudeCompatibilityError, resolveClaudeCompatibility } from './claude-compatibility.mjs';
26
21
  import { codexCompatibilityError, resolveCodexCompatibility } from './codex-compatibility.mjs';
27
22
  import { providerFreeProbe } from './mcp.mjs';
28
23
  import { codexPolicyJson, makePolicy, publicPolicy, isProfile } from './policy.mjs';
29
24
  import { readDiagnostics } from './claude-observation.mjs';
30
25
  import { readCodexDiagnostics } from './codex-diagnostics.mjs';
31
26
 
32
- const VERSION = '0.3.0-beta.13';
27
+ const VERSION = '0.3.0-beta.14';
33
28
  const ENDPOINT = 'https://mcp.ai-brain.aisac.cloud/mcp';
34
29
  const PACKAGE_ROOT = dirname(fileURLToPath(import.meta.url));
35
30
  const LEGACY_DISCLOSURE_VERSION = 'aibrain-paid-context-v1-2026-10-09';
@@ -444,10 +439,13 @@ async function setup(args) {
444
439
  if ((clients === 'claude' || clients === 'both') && !claude) throw new Error('claude_not_found');
445
440
  if ((clients === 'codex' || clients === 'both') && (!codex || !node))
446
441
  throw new Error('codex_or_node_not_found');
447
- const claudeVersion = claude ? nativeVersion(claude) : null;
448
- const claudeSupported = claudeVersion !== null && isTestedNativeVersion('claude', claudeVersion);
449
- if ((clients === 'claude' || clients === 'both') && !claudeSupported)
450
- throw new Error(nativeVersionDiagnostic('claude', claudeVersion ?? ''));
442
+ const claudeCompatibility =
443
+ (clients === 'claude' || clients === 'both') && claude !== null
444
+ ? await resolveClaudeCompatibility(claude, PACKAGE_ROOT)
445
+ : null;
446
+ if (claudeCompatibility?.status === 'rejected')
447
+ throw new Error(claudeCompatibilityError(claudeCompatibility));
448
+ const claudeVersion = claudeCompatibility?.version ?? (claude ? nativeVersion(claude) : null);
451
449
  let codexCompatibility = null;
452
450
  if (clients === 'codex' || clients === 'both') {
453
451
  codexCompatibility = codex ? await resolveCodexCompatibility(codex) : null;
@@ -519,12 +517,14 @@ async function doctor(json) {
519
517
  codex: resolveNative('codex'),
520
518
  node: resolveNative('node'),
521
519
  };
520
+ const claudeCompatibility =
521
+ native.claude !== null ? await resolveClaudeCompatibility(native.claude, PACKAGE_ROOT) : null;
522
+ const codexCompatibility =
523
+ native.codex !== null ? await resolveCodexCompatibility(native.codex) : null;
522
524
  const rawNativeVersions = {
523
- claude: native.claude ? nativeVersion(native.claude) : '',
525
+ claude: claudeCompatibility?.version ?? '',
524
526
  codex: native.codex ? nativeVersion(native.codex) : '',
525
527
  };
526
- const codexCompatibility =
527
- native.codex !== null ? await resolveCodexCompatibility(native.codex) : null;
528
528
  const nativeVersions = {
529
529
  ...rawNativeVersions,
530
530
  codex: codexCompatibility?.version ?? rawNativeVersions.codex,
@@ -533,7 +533,9 @@ async function doctor(json) {
533
533
  const needsCodex = config?.clientSelection === 'codex' || config?.clientSelection === 'both';
534
534
  const nativeReady =
535
535
  (!needsClaude ||
536
- (native.claude !== null && isTestedNativeVersion('claude', nativeVersions.claude))) &&
536
+ (native.claude !== null &&
537
+ (claudeCompatibility?.status === 'qualified' ||
538
+ claudeCompatibility?.status === 'host_checked'))) &&
537
539
  (!needsCodex ||
538
540
  (native.codex !== null &&
539
541
  native.node !== null &&
@@ -552,6 +554,9 @@ async function doctor(json) {
552
554
  node: native.node !== null,
553
555
  },
554
556
  nativeVersions,
557
+ claudeCompatibility: claudeCompatibility?.status ?? 'unavailable',
558
+ claudeCompatibilityReason: claudeCompatibility?.reason ?? 'claude_not_found',
559
+ claudeCompatibilityVersion: claudeCompatibility?.version ?? null,
555
560
  codexCompatibility: codexCompatibility?.status ?? 'unavailable',
556
561
  codexCompatibilityReason: codexCompatibility?.reason ?? 'codex_not_found',
557
562
  codexCompatibilityVersion: codexCompatibility?.version ?? null,
@@ -663,17 +668,8 @@ async function main(argv) {
663
668
  throw new Error('disclosure_outdated');
664
669
  if (config.clientSelection !== command && config.clientSelection !== 'both')
665
670
  throw new Error('client_not_configured');
666
- if (
667
- command === 'claude' &&
668
- (typeof config.claudeVersion !== 'string' ||
669
- !isTestedNativeVersion('claude', config.claudeVersion))
670
- )
671
- throw new Error(
672
- nativeVersionDiagnostic(
673
- 'claude',
674
- typeof config.claudeVersion === 'string' ? config.claudeVersion : '',
675
- ),
676
- );
671
+ // The native executable is authoritative. The stored setup version is
672
+ // metadata only, so patch updates do not block a fresh compatibility check.
677
673
  if (command === 'codex' && typeof config.codexVersion !== 'string')
678
674
  throw new Error('codex_version_unverified');
679
675
  const root = stateRoot();
package/dist/launch.mjs CHANGED
@@ -4,7 +4,8 @@ import { access, chmod, lstat, mkdir, mkdtemp, open, rm, writeFile } from 'node:
4
4
  import { spawn } from 'node:child_process';
5
5
  import { constants } from 'node:fs';
6
6
  import { isAbsolute, join, resolve, sep } from 'node:path';
7
- import { assertTestedNativeVersion, nativeVersion, resolveNative } from './platform.mjs';
7
+ import { nativeVersion, resolveNative } from './platform.mjs';
8
+ import { claudeCompatibilityError, resolveClaudeCompatibility } from './claude-compatibility.mjs';
8
9
  import { codexCompatibilityError, resolveCodexCompatibility } from './codex-compatibility.mjs';
9
10
  import {
10
11
  claudeCheckpointConfigBinding,
@@ -407,8 +408,14 @@ export async function launchClaude(config, root, args) {
407
408
  assertClaudeArgs(launchArgs.args);
408
409
  const executable = resolveNative('claude');
409
410
  if (!executable) throw new Error('claude_not_found');
410
- const currentVersion = nativeVersion(executable);
411
- assertTestedNativeVersion('claude', currentVersion);
411
+ const compatibility = await resolveClaudeCompatibility(executable, validated.packageRoot);
412
+ if (compatibility.status === 'rejected') throw new Error(claudeCompatibilityError(compatibility));
413
+ const pinnedExecutable = compatibility.executable ?? executable;
414
+ if (compatibility.status === 'host_checked')
415
+ process.stderr.write(
416
+ `aibrain: Claude ${compatibility.version ?? 'unknown'} accepted by the local native host contract; end-to-end recovery remains separately qualified.\n`,
417
+ );
418
+ const currentVersion = compatibility.version ?? nativeVersion(pinnedExecutable);
412
419
  const pluginRoot = await assertClaudeAssets(validated.packageRoot);
413
420
  const telemetryActive =
414
421
  validated.telemetry?.enabled === true && validated.telemetry?.disclosedAt != null;
@@ -533,7 +540,7 @@ export async function launchClaude(config, root, args) {
533
540
  let child;
534
541
  try {
535
542
  child = spawn(
536
- executable,
543
+ pinnedExecutable,
537
544
  [
538
545
  '--settings',
539
546
  settingsPath,
package/dist/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@brunobracaioli/aibrain",
3
- "version": "0.3.0-beta.13",
3
+ "version": "0.3.0-beta.14",
4
4
  "type": "module"
5
5
  }
package/dist/platform.mjs CHANGED
@@ -70,7 +70,7 @@ export function nativeVersion(executable, environment = process.env) {
70
70
  export function isTestedNativeVersion(client, version) {
71
71
  const value = version.trim();
72
72
  if (client === 'claude')
73
- return /^(?:(?:claude(?:\s+code)?)\s+)?2\.1\.(?:294|295|296|299|300|301|302)(?:\s+\(Claude\s+Code\))?$/iu.test(
73
+ return /^(?:(?:claude(?:\s+code)?)\s+)?2\.1\.(?:294|295|296|299|300|301|302|303)(?:\s+\(Claude\s+Code\))?$/iu.test(
74
74
  value,
75
75
  );
76
76
  return /^(?:codex(?:-cli)?\s+)?0\.16(?:1\.0|2\.0|2\.1|3\.0)$/iu.test(value);
@@ -85,6 +85,7 @@ const TESTED_NATIVE_VERSIONS = Object.freeze({
85
85
  '2.1.300',
86
86
  '2.1.301',
87
87
  '2.1.302',
88
+ '2.1.303',
88
89
  ]),
89
90
  codex: Object.freeze(['0.161.0', '0.162.0', '0.162.1', '0.163.0']),
90
91
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brunobracaioli/aibrain",
3
- "version": "0.3.0-beta.13",
3
+ "version": "0.3.0-beta.14",
4
4
  "private": false,
5
5
  "description": "AI Brain assisted context beta client for Claude Code and Codex",
6
6
  "license": "UNLICENSED",