@brunobracaioli/aibrain 0.4.0-beta.10 → 0.4.0-beta.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # aisac
2
2
 
3
- The AISAC client for Claude Code and Codex on Linux/WSL, and for Claude Code on
4
- macOS, with Node.js 22 or newer:
3
+ The AISAC client for Claude Code and Codex on Linux, WSL and macOS, with
4
+ Node.js 22 or newer:
5
5
  AI Brain memory, context management and, for AISAC Founding Cohort members,
6
6
  AISAC Engineering.
7
7
 
@@ -19,8 +19,9 @@ For licensed accounts, `aisac claude` downloads the AISAC Engineering pack for
19
19
  the session, verifies its Ed25519 signature against the key pinned in this
20
20
  release, and loads it as the `aisac-engineering` plugin from the session's
21
21
  private folder, which is deleted on exit. Any failure starts Claude without the
22
- pack. `aisac setup --lang pt-BR|en` picks the pack language without a network
23
- call. The pack is never part of this npm package.
22
+ pack. Once set up, `aisac setup --lang pt-BR|en` picks the pack language without a
23
+ network call; on a machine that was never set up it runs the full setup in that
24
+ language. The pack is never part of this npm package.
24
25
 
25
26
  `aisac codex` (Codex 0.164.0 or later) loads the same verified pack for that
26
27
  session only: its skills are added to the Codex app-server the launch owns
@@ -50,8 +51,9 @@ servers: `github` (read-only, token in `GITHUB_PERSONAL_ACCESS_TOKEN`), `supabas
50
51
  - **Login:** the host does it (`/mcp` in Claude Code, `aisac mcp login <id>` for Codex)
51
52
  and keeps it in its own store. No token reaches `~/.aibrain` or AISAC.
52
53
 
53
- Codex capture also requires the `flock` utility from `util-linux`, normally
54
- included in Linux/WSL. The launcher and doctor check availability before a
54
+ On Linux/WSL, Codex capture also requires the `flock` utility from `util-linux`,
55
+ normally included. On macOS it uses `/usr/bin/perl`, `/usr/sbin/sysctl` and
56
+ `/bin/ps` instead. The launcher and doctor check availability before a
55
57
  monitored Codex session starts. Capture coordination is released by the kernel
56
58
  after a process exits unexpectedly. An unidentifiable empty lock left by an
57
59
  older client requires a verified support migration; do not delete checkpoints,
@@ -88,7 +90,7 @@ connection. Its failure or timeout closes only that auxiliary connection.
88
90
  ```sh
89
91
  # Install the published beta:
90
92
  npm install -g @aisac/cli@beta
91
- aisac setup --client both
93
+ aisac setup # configures the clients on PATH; --client claude|codex|both to choose
92
94
  aisac doctor --json
93
95
  aisac claude
94
96
  aisac codex
@@ -102,10 +104,10 @@ Installation does not contact AI Brain, change native client configuration, or
102
104
  enable all paid accounts. Setup requires an existing AI Brain PAT, explicit
103
105
  versioned disclosure, and successful provider-free pilot access. Claude and
104
106
  Codex continue to own their own authentication. Windows native is unsupported;
105
- run the beta inside WSL. On macOS, `aisac setup` configures Claude Code, and
106
- `aisac codex` is refused with `CODEX_MACOS_UNSUPPORTED`. Codex capture needs
107
- Linux procfs and `flock`, so use Codex from Linux/WSL. Pricing and general customer activation are outside
108
- this beta.
107
+ run the beta inside WSL. On macOS, Codex capture locks with the system
108
+ `/usr/bin/perl` (`flock(2)`) and identifies the lock owner with `sysctl` and
109
+ `ps`, so nothing extra needs installing. Pricing and general customer
110
+ activation are outside this beta.
109
111
 
110
112
  For reviewed noninteractive automation only, use a private token file and
111
113
  explicitly acknowledge the displayed disclosure with
@@ -1007,6 +1007,128 @@ async function processIdentity(pid) {
1007
1007
  }
1008
1008
  }
1009
1009
 
1010
+ // macOS has flock(2) but no util-linux helper and no /proc (ADR-0049, spec
1011
+ // codex-macos). The guard helper and the owner identity use fixed system paths:
1012
+ // never PATH lookups, never a shell.
1013
+ const DARWIN_CAPTURE_TOOLS = Object.freeze({
1014
+ perl: '/usr/bin/perl',
1015
+ sysctl: '/usr/sbin/sysctl',
1016
+ ps: '/bin/ps',
1017
+ });
1018
+ const DARWIN_FLOCK_ARGS = Object.freeze([
1019
+ '-MFcntl=:flock',
1020
+ '-e',
1021
+ 'open(my $f, "+<&=", 3) or exit 2; exit(flock($f, LOCK_EX | LOCK_NB) ? 0 : 1)',
1022
+ ]);
1023
+ const DARWIN_TOOL_ENV = Object.freeze({ LC_ALL: 'C', TZ: 'UTC', PATH: '/usr/bin:/bin:/usr/sbin' });
1024
+ const DARWIN_TOOL_TIMEOUT_MS = 2_000;
1025
+ const DARWIN_TOOL_MAX_OUTPUT_BYTES = 4096;
1026
+ const DARWIN_START_TIME = /^[\x20-\x7e]{1,64}$/u;
1027
+ const BOOT_SESSION = /^[a-f0-9-]{36}$/iu;
1028
+ const PS_NO_MATCHING_PROCESS = 1;
1029
+
1030
+ /** Platform and tool paths of the capture lock; replaced only by tests. */
1031
+ let captureLockHost = { platform: process.platform, tools: DARWIN_CAPTURE_TOOLS };
1032
+
1033
+ export function __testSetCaptureLockHost(host) {
1034
+ const previous = captureLockHost;
1035
+ captureLockHost = host;
1036
+ return previous;
1037
+ }
1038
+
1039
+ function guardHelperCommand() {
1040
+ return captureLockHost.platform === 'darwin'
1041
+ ? [captureLockHost.tools.perl, [...DARWIN_FLOCK_ARGS]]
1042
+ : ['flock', ['-x', '-n', '3']];
1043
+ }
1044
+
1045
+ /** @returns {Promise<{ code: number | null, stdout: string }>} */
1046
+ function runDarwinTool(command, args) {
1047
+ return new Promise((resolvePromise) => {
1048
+ const child = spawn(command, args, {
1049
+ env: DARWIN_TOOL_ENV,
1050
+ stdio: ['ignore', 'pipe', 'ignore'],
1051
+ });
1052
+ let stdout = '';
1053
+ let settled = false;
1054
+ const finish = (code) => {
1055
+ if (settled) return;
1056
+ settled = true;
1057
+ clearTimeout(timer);
1058
+ resolvePromise({ code, stdout });
1059
+ };
1060
+ const timer = setTimeout(() => {
1061
+ child.kill('SIGKILL');
1062
+ finish(null);
1063
+ }, DARWIN_TOOL_TIMEOUT_MS);
1064
+ child.stdout.setEncoding('utf8');
1065
+ child.stdout.on('data', (chunk) => {
1066
+ stdout += chunk;
1067
+ if (Buffer.byteLength(stdout) > DARWIN_TOOL_MAX_OUTPUT_BYTES) {
1068
+ child.kill('SIGKILL');
1069
+ finish(null);
1070
+ }
1071
+ });
1072
+ child.once('error', () => finish(null));
1073
+ child.once('close', (code) => finish(code));
1074
+ });
1075
+ }
1076
+
1077
+ async function darwinBootSession() {
1078
+ const result = await runDarwinTool(captureLockHost.tools.sysctl, ['-n', 'kern.bootsessionuuid']);
1079
+ const value = result.stdout.trim();
1080
+ return result.code === 0 && BOOT_SESSION.test(value) ? value.toUpperCase() : null;
1081
+ }
1082
+
1083
+ async function darwinProcessIdentity(pid) {
1084
+ if (!Number.isSafeInteger(pid) || pid <= 0) return { status: 'invalid' };
1085
+ const bootSession = await darwinBootSession();
1086
+ if (bootSession === null) return { status: 'unverifiable' };
1087
+ const result = await runDarwinTool(captureLockHost.tools.ps, [
1088
+ '-o',
1089
+ 'lstart=',
1090
+ '-p',
1091
+ String(pid),
1092
+ ]);
1093
+ const startTime = result.stdout.trim();
1094
+ // BSD ps exits 1 with no output when no process matches the pid.
1095
+ if (result.code === PS_NO_MATCHING_PROCESS && startTime === '') return { status: 'dead' };
1096
+ if (result.code !== 0 || !DARWIN_START_TIME.test(startTime)) return { status: 'unverifiable' };
1097
+ return { status: 'live', startTime, bootSession };
1098
+ }
1099
+
1100
+ async function darwinSentinelOwner(value) {
1101
+ if (
1102
+ !isRecord(value) ||
1103
+ !hasOnlyKeys(value, [
1104
+ 'schema',
1105
+ 'backend',
1106
+ 'platform',
1107
+ 'pid',
1108
+ 'startTime',
1109
+ 'bootSession',
1110
+ 'nonce',
1111
+ ]) ||
1112
+ value.schema !== 1 ||
1113
+ value.backend !== 'flock' ||
1114
+ value.platform !== 'darwin' ||
1115
+ !isSafeBoundedInteger(value.pid, 1, 4_000_000_000) ||
1116
+ typeof value.startTime !== 'string' ||
1117
+ !DARWIN_START_TIME.test(value.startTime) ||
1118
+ typeof value.bootSession !== 'string' ||
1119
+ !BOOT_SESSION.test(value.bootSession) ||
1120
+ typeof value.nonce !== 'string' ||
1121
+ !isUuid(value.nonce)
1122
+ )
1123
+ return { status: 'unverifiable' };
1124
+ const observer = await darwinBootSession();
1125
+ if (observer === null) return { status: 'unverifiable' };
1126
+ if (observer !== value.bootSession.toUpperCase()) return { status: 'dead' };
1127
+ const current = await darwinProcessIdentity(value.pid);
1128
+ if (current.status !== 'live') return { status: current.status };
1129
+ return current.startTime === value.startTime ? { status: 'live' } : { status: 'dead' };
1130
+ }
1131
+
1010
1132
  async function captureSentinelOwner(lockPath, fsOps) {
1011
1133
  let info;
1012
1134
  try {
@@ -1025,6 +1147,7 @@ async function captureSentinelOwner(lockPath, fsOps) {
1025
1147
  } catch {
1026
1148
  return { status: 'unverifiable' };
1027
1149
  }
1150
+ if (captureLockHost.platform === 'darwin') return darwinSentinelOwner(value);
1028
1151
  if (
1029
1152
  !isRecord(value) ||
1030
1153
  !hasOnlyKeys(value, [
@@ -1060,10 +1183,23 @@ async function captureSentinelOwner(lockPath, fsOps) {
1060
1183
  return current.startTime === value.startTime ? { status: 'live' } : { status: 'dead' };
1061
1184
  }
1062
1185
 
1063
- async function ensureCaptureSentinel(lockPath, task) {
1186
+ async function ownSentinelRecord() {
1187
+ if (captureLockHost.platform === 'darwin') {
1188
+ const identity = await darwinProcessIdentity(process.pid);
1189
+ if (identity.status !== 'live') throw captureLockError('capture_lock_owner_unverifiable');
1190
+ return {
1191
+ schema: 1,
1192
+ backend: 'flock',
1193
+ platform: 'darwin',
1194
+ pid: process.pid,
1195
+ startTime: identity.startTime,
1196
+ bootSession: identity.bootSession,
1197
+ nonce: randomUUID(),
1198
+ };
1199
+ }
1064
1200
  const identity = await processIdentity(process.pid);
1065
1201
  if (identity.status !== 'live') throw captureLockError('capture_lock_owner_unverifiable');
1066
- const metadata = `${JSON.stringify({
1202
+ return {
1067
1203
  schema: 1,
1068
1204
  backend: 'flock',
1069
1205
  pid: process.pid,
@@ -1071,7 +1207,11 @@ async function ensureCaptureSentinel(lockPath, task) {
1071
1207
  bootId: identity.bootId,
1072
1208
  pidNamespace: identity.pidNamespace,
1073
1209
  nonce: randomUUID(),
1074
- })}\n`;
1210
+ };
1211
+ }
1212
+
1213
+ async function ensureCaptureSentinel(lockPath, task) {
1214
+ const metadata = `${JSON.stringify(await ownSentinelRecord())}\n`;
1075
1215
  while (true) {
1076
1216
  const temporary = `${lockPath}.${process.pid}.${randomUUID()}.tmp`;
1077
1217
  let contention = false;
@@ -1149,7 +1289,8 @@ async function withAdvisoryCaptureLock(directory, task) {
1149
1289
  while (true) {
1150
1290
  let helper;
1151
1291
  try {
1152
- helper = spawn('flock', ['-x', '-n', '3'], {
1292
+ const [command, args] = guardHelperCommand();
1293
+ helper = spawn(command, args, {
1153
1294
  stdio: ['ignore', 'ignore', 'ignore', guardHandle.fd],
1154
1295
  });
1155
1296
  const acquired = await new Promise((resolvePromise, rejectPromise) => {
@@ -20,6 +20,8 @@ const MAX_AUXILIARY_IN_FLIGHT = 32;
20
20
  const MAX_AUXILIARY_NOTIFICATIONS = 256;
21
21
  const DEFAULT_AUXILIARY_IDLE_TIMEOUT_MS = 120_000;
22
22
  const NORMAL_DISCOVERY_CLOSE_CODES = new Set([1000, 1001, 1005]);
23
+ // Smallest sun_path among supported hosts: macOS 104 bytes, minus the terminator.
24
+ const UNIX_SOCKET_PATH_MAX_BYTES = 103;
23
25
 
24
26
  function repoRoot() {
25
27
  return fileURLToPath(new URL('..', import.meta.url)).replace(/\/$/u, '');
@@ -1022,6 +1024,10 @@ async function main() {
1022
1024
  });
1023
1025
  });
1024
1026
  try {
1027
+ // macOS sun_path holds 104 bytes including the terminator (Linux 108); refuse a
1028
+ // path that the kernel would truncate or reject instead of binding elsewhere.
1029
+ if (Buffer.byteLength(socketPath) > UNIX_SOCKET_PATH_MAX_BYTES)
1030
+ throw new Error('terminal_bridge_socket_path_too_long');
1025
1031
  await new Promise((resolve, reject) => {
1026
1032
  server.once('error', reject);
1027
1033
  server.listen(socketPath, async () => {
@@ -16,7 +16,7 @@ export const MAX_LOG_FILES = 3;
16
16
  const LOCK_WAIT_MS = 250;
17
17
  const LOCK_STALE_MS = 30_000;
18
18
  const ADMISSION_MS = 60 * 60 * 1000;
19
- export const ADAPTER_VERSION = '0.4.0-beta.10';
19
+ export const ADAPTER_VERSION = '0.4.0-beta.12';
20
20
  const MAX_DIAGNOSTIC_COUNT = 10_000_000;
21
21
  const MAX_DIAGNOSTIC_BYTES = 128 * 1024 * 1024;
22
22
  const CHECKPOINT_BACKENDS = new Set(['native_store', 'private_file']);
package/dist/cli.mjs CHANGED
@@ -17,8 +17,6 @@ import {
17
17
  } from './state.mjs';
18
18
  import {
19
19
  captureLockPlatformCheck,
20
- codexPlatformCheck,
21
- defaultClientSelection,
22
20
  nativeVersion,
23
21
  platformCheck,
24
22
  resolveNative,
@@ -27,6 +25,7 @@ import { launchClaude, launchCodex } from './launch.mjs';
27
25
  import { claudeCompatibilityError, resolveClaudeCompatibility } from './claude-compatibility.mjs';
28
26
  import { codexCompatibilityError, resolveCodexCompatibility } from './codex-compatibility.mjs';
29
27
  import { providerFreeProbe } from './mcp.mjs';
28
+ import { tokenFailureGuide, tokenPromptGuide } from './token-guide.mjs';
30
29
  import { codexPolicyJson, makePolicy, publicPolicy, isProfile } from './policy.mjs';
31
30
  import { readDiagnostics } from './claude-observation.mjs';
32
31
  import { readCodexDiagnostics } from './codex-diagnostics.mjs';
@@ -42,7 +41,7 @@ import { extensionsSummary, hooksCommand, mcpCommand } from './extensions-cli.mj
42
41
  import { sessionServers } from './mcp-servers.mjs';
43
42
  import { anyFormatter } from './guardrails/format.mjs';
44
43
 
45
- const VERSION = '0.4.0-beta.10';
44
+ const VERSION = '0.4.0-beta.12';
46
45
  // The command is `aisac`; `aibrain` is an alias installed from the same entry.
47
46
  const COMMAND = 'aisac';
48
47
  const ENDPOINT = 'https://mcp.ai-brain.aisac.cloud/mcp';
@@ -53,6 +52,9 @@ function usage() {
53
52
  console.log(
54
53
  `${COMMAND} ${VERSION}\n\nUsage:\n ${COMMAND} setup [--client claude|codex|both] [--context-profile upstream|conservative]\n [--compact-at 1..95] [--keep-threshold 0..1] [--preserve-recent 1..100]\n [--min-reduction 0..1] [--truncate-head-chars 0..4096]\n [--max-state-tokens 1000..25000] [--max-request-tokens 1000..30000]\n [--tool-policy compatible|read_only] [--goal TEXT]\n ${COMMAND} doctor [--json]\n ${COMMAND} status [--json]\n ${COMMAND} disable\n ${COMMAND} setup --undo\n ${COMMAND} claude [-- native-args...]\n ${COMMAND} codex [-- native-args...]\n\nPolicy notes:\n Automatic monitoring asks for compaction at compact-at percent; state/request\n limits are estimated upper bounds and do not override server quotas.\n min-reduction, truncate-head-chars and tool-policy apply only to Claude;\n Codex keeps native compaction and same-session recovery.`,
55
54
  );
55
+ console.log(
56
+ 'Clients: setup without --client configures the clients installed on PATH (Claude Code, Codex, or both).',
57
+ );
56
58
  console.log(
57
59
  'Telemetry: setup --telemetry on|off (omitted preserves the stored choice; fresh defaults off).',
58
60
  );
@@ -60,7 +62,7 @@ function usage() {
60
62
  'Claude shadow: setup --claude-shadow on|off and --claude-diagnostics on|off (preview stays applied=false).',
61
63
  );
62
64
  console.log(
63
- 'AISAC Engineering: `aisac claude` and `aisac codex` (Codex 0.164+) load the licensed method; setup --lang pt-BR|en picks its language; setup and doctor report the license; status stays offline. `aibrain` is an alias of `aisac`.',
65
+ 'AISAC Engineering: `aisac claude` and `aisac codex` (Codex 0.164+) load the licensed method; setup --lang pt-BR|en picks its language (before the first setup it runs the full setup); setup and doctor report the license; status stays offline. `aibrain` is an alias of `aisac`.',
64
66
  );
65
67
  console.log(
66
68
  'Guardrails: `aisac hooks` lists them (a checklist on a terminal); `aisac hooks enable|disable <id>...` and `aisac hooks reset` change the next `aisac claude` session.',
@@ -117,6 +119,18 @@ async function maskedPrompt() {
117
119
  }
118
120
  }
119
121
 
122
+ /**
123
+ * A setup error that carries the PAT step-by-step guide when the failure means
124
+ * the subscriber needs a (different) token.
125
+ * @param {string} code @param {'pt-BR' | 'en'} locale @param {string} endpoint
126
+ */
127
+ function setupTokenError(code, locale, endpoint) {
128
+ const error = new Error(code);
129
+ const guide = tokenFailureGuide(code, locale, endpoint);
130
+ if (guide !== null) Object.defineProperty(error, 'guide', { value: guide });
131
+ return error;
132
+ }
133
+
120
134
  /** @param {string[]} args @param {string} name */
121
135
  function option(args, name) {
122
136
  const index = args.indexOf(name);
@@ -176,8 +190,11 @@ function policyOverrides(args) {
176
190
  return values;
177
191
  }
178
192
 
179
- /** @param {string[]} args @param {Awaited<ReturnType<typeof readConfig>>} previous */
180
- function selectedPolicy(args, previous) {
193
+ /**
194
+ * @param {string[]} args @param {Awaited<ReturnType<typeof readConfig>>} previous
195
+ * @param {'claude' | 'codex' | 'both'} clients
196
+ */
197
+ function selectedPolicy(args, previous, clients) {
181
198
  const explicitProfile = option(args, '--context-profile');
182
199
  const explicitV2 =
183
200
  explicitProfile !== undefined ||
@@ -216,7 +233,6 @@ function selectedPolicy(args, previous) {
216
233
  if (option(args, '--goal') === undefined && inherited.goal !== undefined)
217
234
  overrides.goal = inherited.goal;
218
235
  }
219
- const clients = option(args, '--client') ?? defaultClientSelection();
220
236
  if (
221
237
  clients === 'codex' &&
222
238
  args.some(
@@ -405,6 +421,32 @@ async function disclosureAccepted(
405
421
  }
406
422
  }
407
423
 
424
+ /**
425
+ * Client selection when `--client` is omitted: the native clients found on PATH. Codex
426
+ * also needs `node`, the same pair the explicit `--client codex` path checks.
427
+ * @returns {'claude' | 'codex' | 'both' | null}
428
+ */
429
+ function detectedClients() {
430
+ const hasClaude = resolveNative('claude') !== null;
431
+ const hasCodex = resolveNative('codex') !== null && resolveNative('node') !== null;
432
+ if (hasClaude && hasCodex) return 'both';
433
+ if (hasClaude) return 'claude';
434
+ return hasCodex ? 'codex' : null;
435
+ }
436
+
437
+ /** @param {string | undefined} requested @returns {'claude' | 'codex' | 'both'} */
438
+ function setupClients(requested) {
439
+ if (requested !== undefined) {
440
+ if (requested !== 'claude' && requested !== 'codex' && requested !== 'both')
441
+ throw new Error('invalid_client');
442
+ return requested;
443
+ }
444
+ const detected = detectedClients();
445
+ if (detected === null) throw new Error('no_native_client_found');
446
+ console.log(`Clients detected on PATH: ${detected} (pass --client to choose).`);
447
+ return detected;
448
+ }
449
+
408
450
  /** @param {string[]} args */
409
451
  async function setup(args) {
410
452
  if (args.includes('--yes')) throw new Error('explicit_disclosure_flag_required');
@@ -442,8 +484,10 @@ async function setup(args) {
442
484
  throw new Error('setup_undo_cannot_have_options');
443
485
  const lang = option(args, '--lang');
444
486
  if (lang !== undefined && !isPackLocale(lang)) throw new Error('invalid_lang');
445
- if (lang !== undefined && args.length === 2) return setPackLocale(lang);
446
487
  const previous = await readConfig();
488
+ // `--lang` alone only switches the language of an existing setup. Before the first
489
+ // setup it runs the full setup (token included) and stores the language at the end.
490
+ if (lang !== undefined && args.length === 2 && previous !== null) return setPackLocale(lang);
447
491
  if (await updateLocalOptOut(previous, args)) return;
448
492
  const localOptOut =
449
493
  option(args, '--telemetry') === 'off' &&
@@ -451,12 +495,7 @@ async function setup(args) {
451
495
  if (localOptOut) return disableTelemetryLocally(previous);
452
496
  const platform = platformCheck();
453
497
  if (!platform.ok) throw new Error(`${platform.code}:${platform.message}`);
454
- const clients = option(args, '--client') ?? defaultClientSelection();
455
- if (!['claude', 'codex', 'both'].includes(clients)) throw new Error('invalid_client');
456
- if (clients !== 'claude') {
457
- const codexPlatform = codexPlatformCheck();
458
- if (!codexPlatform.ok) throw new Error(`${codexPlatform.code}:${codexPlatform.message}`);
459
- }
498
+ const clients = setupClients(option(args, '--client'));
460
499
  if (
461
500
  clients === 'codex' &&
462
501
  (option(args, '--claude-shadow') !== undefined ||
@@ -465,7 +504,7 @@ async function setup(args) {
465
504
  throw new Error('claude_observation_requires_claude_client');
466
505
  const endpoint = option(args, '--endpoint') ?? ENDPOINT;
467
506
  validateEndpoint(endpoint);
468
- const selection = selectedPolicy(args, previous);
507
+ const selection = selectedPolicy(args, previous, clients);
469
508
  const telemetry = telemetrySelection(args, previous);
470
509
  const claudeObservation = claudeObservationSelection(args, previous);
471
510
  const policy = selection.policy;
@@ -487,10 +526,18 @@ async function setup(args) {
487
526
  )
488
527
  throw new Error('disclosure_not_accepted');
489
528
  const root = await ensureStateRoot();
529
+ const locale = resolvePackLocale(lang ?? (await readStoredLocale(root)));
490
530
  const tokenPath = option(args, '--token-file');
491
- let token = tokenPath ? await readPrivateToken(tokenPath) : await maskedPrompt();
531
+ if (!tokenPath && stdin.isTTY && stdout.isTTY)
532
+ console.log(`\n${tokenPromptGuide(locale, endpoint)}\n`);
533
+ let token;
534
+ try {
535
+ token = tokenPath ? await readPrivateToken(tokenPath) : await maskedPrompt();
536
+ } catch (error) {
537
+ throw setupTokenError(errorMessage(error), locale, endpoint);
538
+ }
492
539
  token = token.trim();
493
- if (!token) throw new Error('empty_token');
540
+ if (!token) throw setupTokenError('empty_token', locale, endpoint);
494
541
  const claude = resolveNative('claude');
495
542
  const codex = resolveNative('codex');
496
543
  const node = resolveNative('node');
@@ -536,7 +583,7 @@ async function setup(args) {
536
583
  ? await providerFreeProbe(endpoint, token, policy, clients)
537
584
  : await providerFreeProbe(endpoint, token);
538
585
  } catch (error) {
539
- throw new Error(`provider_free_probe_failed:${errorMessage(error)}`);
586
+ throw setupTokenError(`provider_free_probe_failed:${errorMessage(error)}`, locale, endpoint);
540
587
  }
541
588
  try {
542
589
  const configWithToken = { ...config, token, tokenSha256: await tokenDigest(token), probe };
@@ -800,6 +847,8 @@ main(cliArgs).catch((error) => {
800
847
  );
801
848
  } else {
802
849
  console.error(`${COMMAND}: ${errorMessage(error)}`);
850
+ const guide = /** @type {{ guide?: unknown }} */ (error ?? {}).guide;
851
+ if (typeof guide === 'string') console.error(`\n${guide}`);
803
852
  }
804
853
  process.exitCode = 1;
805
854
  });
package/dist/launch.mjs CHANGED
@@ -678,12 +678,7 @@ export async function launchCodex(config, root, args, dependencies = {}) {
678
678
  const validated = assertConfig(config);
679
679
  assertCodexArgs(args);
680
680
  const captureLockPlatform = captureLockPlatformCheck();
681
- if (!captureLockPlatform.ok)
682
- throw new Error(
683
- captureLockPlatform.code === 'CODEX_MACOS_UNSUPPORTED'
684
- ? `${captureLockPlatform.code}:${captureLockPlatform.message}`
685
- : captureLockPlatform.code,
686
- );
681
+ if (!captureLockPlatform.ok) throw new Error(captureLockPlatform.code);
687
682
  const executable = resolveNative('codex');
688
683
  const node = resolveNative('node');
689
684
  if (!executable || !node) throw new Error('codex_or_node_not_found');
package/dist/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@brunobracaioli/aibrain",
3
- "version": "0.4.0-beta.10",
3
+ "version": "0.4.0-beta.12",
4
4
  "type": "module"
5
5
  }
package/dist/platform.mjs CHANGED
@@ -5,8 +5,8 @@ import { spawnSync } from 'node:child_process';
5
5
  import { delimiter, isAbsolute, join } from 'node:path';
6
6
 
7
7
  /**
8
- * Host operating system gate shared by every command. Linux (including WSL)
9
- * and macOS run the Claude Code path; Codex has its own gate below.
8
+ * Host operating system gate shared by every command: Linux (including WSL)
9
+ * and macOS. Codex capture adds its lock-backend check below.
10
10
  * @param {NodeJS.Platform} [platform]
11
11
  */
12
12
  export function platformCheck(platform = process.platform) {
@@ -29,34 +29,44 @@ export function platformCheck(platform = process.platform) {
29
29
  }
30
30
 
31
31
  /**
32
- * Codex capture identifies lock owners through Linux procfs (boot id, PID
33
- * namespace, process start time) and coordinates through util-linux flock.
34
- * macOS has neither, so Codex stays Linux/WSL-only until a reviewed backend
35
- * exists (docs/specs/aisac-macos-claude.md).
32
+ * System tools the macOS capture lock runs by absolute path (ADR-0049): perl
33
+ * takes flock(2) on the guard descriptor; sysctl and ps identify the owner.
34
+ */
35
+ export const DARWIN_CAPTURE_TOOLS = Object.freeze(['/usr/bin/perl', '/usr/sbin/sysctl', '/bin/ps']);
36
+
37
+ /** @param {string} path */
38
+ function isExecutableFile(path) {
39
+ try {
40
+ if (!statSync(path).isFile()) return false;
41
+ accessSync(path, constants.X_OK);
42
+ return true;
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Codex capture needs a kernel-owned guard lock: util-linux flock on Linux/WSL,
50
+ * the system perl's flock(2) on macOS (spec codex-macos).
51
+ * @param {NodeJS.ProcessEnv} [environment]
36
52
  * @param {NodeJS.Platform} [platform]
53
+ * @param {readonly string[]} [darwinTools]
37
54
  */
38
- export function codexPlatformCheck(platform = process.platform) {
55
+ export function captureLockPlatformCheck(
56
+ environment = process.env,
57
+ platform = process.platform,
58
+ darwinTools = DARWIN_CAPTURE_TOOLS,
59
+ ) {
39
60
  const host = platformCheck(platform);
40
61
  if (!host.ok) return host;
41
- if (platform === 'darwin')
62
+ if (platform === 'darwin') {
63
+ if (darwinTools.every(isExecutableFile)) return { ok: true };
42
64
  return {
43
65
  ok: false,
44
- code: 'CODEX_MACOS_UNSUPPORTED',
45
- message:
46
- 'aisac codex is not available on macOS yet. Use `aisac setup --client claude` and `aisac claude`, or run Codex from Linux/WSL.',
66
+ code: 'CODEX_CAPTURE_LOCK_BACKEND_UNAVAILABLE',
67
+ message: `Codex capture on macOS requires ${darwinTools.join(', ')}.`,
47
68
  };
48
- return { ok: true };
49
- }
50
-
51
- /** Clients configured when `--client` is omitted: only those this OS can run. */
52
- export function defaultClientSelection(platform = process.platform) {
53
- return codexPlatformCheck(platform).ok ? 'both' : 'claude';
54
- }
55
-
56
- /** @param {NodeJS.ProcessEnv} [environment] @param {NodeJS.Platform} [platform] */
57
- export function captureLockPlatformCheck(environment = process.env, platform = process.platform) {
58
- const codex = codexPlatformCheck(platform);
59
- if (!codex.ok) return codex;
69
+ }
60
70
  if (resolveNative('flock', environment) === null)
61
71
  return {
62
72
  ok: false,
@@ -0,0 +1,105 @@
1
+ // Step-by-step guidance for obtaining an AI Brain PAT during `aisac setup`.
2
+ // Spec aibrain-subscriber-beta § Setup step 3: guide users to the existing
3
+ // dashboard token flow instead of failing with a bare error code. The text
4
+ // never contains the token or any account data; the URL is the endpoint origin.
5
+
6
+ const DEFAULT_DASHBOARD = 'https://mcp.ai-brain.aisac.cloud';
7
+
8
+ /** @typedef {'pt-BR' | 'en'} GuideLocale */
9
+
10
+ /** Setup failures that mean "you need a (different) token", mapped to a reason key. */
11
+ const TOKEN_FAILURES = Object.freeze({
12
+ empty_token: 'empty',
13
+ token_file_required_in_noninteractive_mode: 'noninteractive',
14
+ 'provider_free_probe_failed:mcp_auth_denied': 'denied',
15
+ 'provider_free_probe_failed:mcp_paid_plan_required': 'unlicensed',
16
+ });
17
+
18
+ const TEXT = Object.freeze({
19
+ 'pt-BR': {
20
+ intro: 'O aisac precisa de um token pessoal (PAT) do seu AI Brain.',
21
+ missing: 'Não tem um? Gere em um minuto:',
22
+ login: (/** @type {string} */ url) =>
23
+ ` 1. Abra ${url} e entre com o MESMO e-mail usado na compra\n (chega um código de 6 dígitos). A licença fica vinculada a esse e-mail.`,
24
+ label:
25
+ ' 2. No painel, dê um rótulo (ex.: meu-mac) e escolha o agente\n (Anthropic para Claude Code, OpenAI para Codex). Isso só organiza a atribuição.',
26
+ generate: ' 3. Clique em "Gerar token" e copie na hora: ele aparece uma única vez.',
27
+ pasteHere: ' 4. Cole abaixo em "AI Brain PAT". A digitação fica oculta.',
28
+ rerun: ' 4. Rode `aisac setup` de novo e cole o token em "AI Brain PAT" (fica oculto).',
29
+ safety:
30
+ 'Nunca coloque o token em argumento de comando, arquivo do repositório ou perfil do shell.',
31
+ support: 'Se o e-mail da compra não entrar ou não tiver a licença, fale com o suporte AISAC.',
32
+ reasons: {
33
+ empty: 'Nenhum token foi informado.',
34
+ noninteractive:
35
+ 'Sem um terminal interativo, o token precisa vir de `--token-file` (um arquivo privado, modo 600).',
36
+ denied:
37
+ 'O AI Brain recusou este token: ele está incompleto, foi revogado ou não existe. Gere um novo.',
38
+ unlicensed:
39
+ 'Este token pertence a uma conta sem a licença. Gere o token entrando com o e-mail usado na compra.',
40
+ },
41
+ },
42
+ en: {
43
+ intro: 'aisac needs a personal access token (PAT) from your AI Brain.',
44
+ missing: "Don't have one? Create it in a minute:",
45
+ login: (/** @type {string} */ url) =>
46
+ ` 1. Open ${url} and sign in with the SAME email you used to buy\n (a 6-digit code arrives by email). The license is tied to that email.`,
47
+ label:
48
+ ' 2. In the dashboard, enter a label (e.g. my-mac) and pick the agent\n (Anthropic for Claude Code, OpenAI for Codex). It only organizes attribution.',
49
+ generate: ' 3. Click "Gerar token" and copy it right away: it is shown only once.',
50
+ pasteHere: ' 4. Paste it below at "AI Brain PAT". Typing stays hidden.',
51
+ rerun: ' 4. Run `aisac setup` again and paste the token at "AI Brain PAT" (hidden).',
52
+ safety: 'Never put the token in command arguments, a repository file or a shell profile.',
53
+ support: 'If the purchase email cannot sign in or has no license, contact AISAC support.',
54
+ reasons: {
55
+ empty: 'No token was entered.',
56
+ noninteractive:
57
+ 'Without an interactive terminal the token must come from `--token-file` (a private file, mode 600).',
58
+ denied:
59
+ 'AI Brain rejected this token: it is incomplete, revoked or unknown. Create a new one.',
60
+ unlicensed:
61
+ 'This token belongs to an account without the license. Create the token signed in with the purchase email.',
62
+ },
63
+ },
64
+ });
65
+
66
+ /** The dashboard origin derived from an https endpoint, else the production dashboard. @param {string} endpoint */
67
+ export function dashboardUrl(endpoint) {
68
+ try {
69
+ const url = new URL(endpoint);
70
+ return url.protocol === 'https:' ? url.origin : DEFAULT_DASHBOARD;
71
+ } catch {
72
+ return DEFAULT_DASHBOARD;
73
+ }
74
+ }
75
+
76
+ /** @param {GuideLocale} locale @param {string} endpoint @param {'prompt' | 'rerun'} step */
77
+ function steps(locale, endpoint, step) {
78
+ const text = TEXT[locale];
79
+ return [
80
+ text.intro,
81
+ text.missing,
82
+ text.login(dashboardUrl(endpoint)),
83
+ text.label,
84
+ text.generate,
85
+ step === 'prompt' ? text.pasteHere : text.rerun,
86
+ text.safety,
87
+ ].join('\n');
88
+ }
89
+
90
+ /** Guidance printed right before the masked PAT prompt. @param {GuideLocale} locale @param {string} endpoint */
91
+ export function tokenPromptGuide(locale, endpoint) {
92
+ return steps(locale, endpoint, 'prompt');
93
+ }
94
+
95
+ /**
96
+ * Guidance for a setup failure caused by a missing, rejected or unlicensed token,
97
+ * or null when the failure has another cause.
98
+ * @param {string} code @param {GuideLocale} locale @param {string} endpoint
99
+ */
100
+ export function tokenFailureGuide(code, locale, endpoint) {
101
+ const reason = /** @type {Record<string, keyof typeof TEXT.en.reasons>} */ (TOKEN_FAILURES)[code];
102
+ if (reason === undefined) return null;
103
+ const text = TEXT[locale];
104
+ return [text.reasons[reason], '', steps(locale, endpoint, 'rerun'), text.support].join('\n');
105
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brunobracaioli/aibrain",
3
- "version": "0.4.0-beta.10",
3
+ "version": "0.4.0-beta.12",
4
4
  "private": false,
5
5
  "description": "AISAC client for Claude Code and Codex: AI Brain memory, context management and AISAC Engineering",
6
6
  "license": "UNLICENSED",