@eventmodelers/cli 1.0.55 → 1.0.57

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/cli.js CHANGED
@@ -384,6 +384,10 @@ async function promptPasteBlock() {
384
384
  // rather than silently disabling platform sync.
385
385
  const DEFAULT_BASE_URL = 'https://api.eventmodelers.ai';
386
386
 
387
+ // This CLI's own version, stamped into every install manifest so the global modeling
388
+ // install can tell whether it was written by the version now running (see ensureGlobalKit).
389
+ const CLI_VERSION = readJsonSafe(join(__dirname, 'package.json')).version || '0.0.0';
390
+
387
391
  // Canonical order the account page pastes values in, regardless of which fields a
388
392
  // given stack actually requires — a modeling-kit install (no boardId required) still
389
393
  // gets a paste containing all 4 fields, so we must not drop the ones we don't need.
@@ -745,7 +749,11 @@ async function installStack(stackKey, stackCfg, options = {}) {
745
749
  console.log('🚀 Eventmodelers CLI\n');
746
750
  console.log(`Using: ${stackKey} (${stackCfg.label})\n`);
747
751
 
748
- const targetDir = process.cwd();
752
+ // Almost always the cwd. The exception is the global modeling install, which is
753
+ // scaffolded into ~/.eventmodelers/kit from wherever `run --standalone` was invoked
754
+ // (see ensureGlobalKit) — the mirror image of options.templatesSource below: where we
755
+ // install TO, versus where we install FROM.
756
+ const targetDir = options.targetDir ? resolve(options.targetDir) : process.cwd();
749
757
  // `init --git <url>` passes a resolved clone dir's templates/ here instead — every
750
758
  // other input (STACKS, MODELING_KIT, BRIDGE_KIT) keeps using the built-in path.
751
759
  const templatesSource = options.templatesSource || join(__dirname, 'stacks', stackKey, 'templates');
@@ -949,7 +957,11 @@ async function installStack(stackKey, stackCfg, options = {}) {
949
957
  }
950
958
 
951
959
  // --- 4. Install kit dependencies ---
952
- if (existsSync(join(kitDir, 'package.json'))) {
960
+ // modeling-kit's package.json has no dependencies at all — it exists purely for its
961
+ // `"type": "module"`, so lib/config.js can be ESM-imported. Running npm for that buys
962
+ // a lockfile and nothing else, and it sits on the critical path of the global
963
+ // install's first-use scaffold (ensureGlobalKit), so skip it.
964
+ if (!isModelingKit && existsSync(join(kitDir, 'package.json'))) {
953
965
  console.log('📦 Installing kit dependencies...');
954
966
  try {
955
967
  execSync('npm install', { cwd: kitDir, stdio: ['ignore', 'inherit', 'inherit'] });
@@ -960,43 +972,48 @@ async function installStack(stackKey, stackCfg, options = {}) {
960
972
  }
961
973
 
962
974
  // --- 5. Credentials ---
963
- console.log('🔐 Configuring credentials...');
964
-
965
- // Written at the project root (not inside the kit dir) so a modeling-kit install
966
- // and a build-kit install in the same project share one config.json instead of
967
- // each prompting for and storing its own copy of the same credentials.
968
- const configPath = options.configPath
969
- ? resolve(targetDir, options.configPath)
970
- : join(targetDir, '.eventmodelers', 'config.json');
971
-
972
- const requiredFields = stackCfg.needsBoardId
973
- ? ['organizationId', 'boardId', 'token']
974
- : ['organizationId', 'token'];
975
-
976
- const effective = loadEffectiveConfig(targetDir, kitDir, options.configPath);
977
- if (effective.sources.length > 1) {
978
- console.log(`\n ✓ Found shared defaults in ${effective.sources[0]}`);
979
- }
980
-
981
- const config = await configureCredentials({
982
- config: effective.config,
983
- configPath,
984
- targetDir,
985
- requiredFields,
986
- boardIdOptional: !stackCfg.needsBoardId,
987
- overrides: options.credentialOverrides,
988
- print: options.print,
989
- force: options.force,
990
- });
975
+ // Skipped by the global modeling install (ensureGlobalKit): that one dir is reused
976
+ // across every board and account, so it deliberately keeps no credentials at rest.
977
+ // Each run resolves its own and hands them to the agent in memory instead.
978
+ if (!options.skipCredentials) {
979
+ console.log('🔐 Configuring credentials...');
980
+
981
+ // Written at the project root (not inside the kit dir) so a modeling-kit install
982
+ // and a build-kit install in the same project share one config.json instead of
983
+ // each prompting for and storing its own copy of the same credentials.
984
+ const configPath = options.configPath
985
+ ? resolve(targetDir, options.configPath)
986
+ : join(targetDir, '.eventmodelers', 'config.json');
991
987
 
992
- // Register the MCP server up front so it's available from the very first
993
- // `claude` invocation (whether that's an interactive session opened right
994
- // after install, or the agent loop's first spawn) instead of only appearing
995
- // once `run`/`run --modeling` or `init-mcp` happens to run. Safe to write
996
- // even without a token yet — the file only ever holds the env-var
997
- // placeholder, never the literal secret (see connect/SKILL.md's Security notes).
998
- ensureMcpRegistered(targetDir, config.baseUrl || DEFAULT_BASE_URL);
999
- ensureEnvToken(targetDir, config.token);
988
+ const requiredFields = stackCfg.needsBoardId
989
+ ? ['organizationId', 'boardId', 'token']
990
+ : ['organizationId', 'token'];
991
+
992
+ const effective = loadEffectiveConfig(targetDir, kitDir, options.configPath);
993
+ if (effective.sources.length > 1) {
994
+ console.log(`\n ✓ Found shared defaults in ${effective.sources[0]}`);
995
+ }
996
+
997
+ const config = await configureCredentials({
998
+ config: effective.config,
999
+ configPath,
1000
+ targetDir,
1001
+ requiredFields,
1002
+ boardIdOptional: !stackCfg.needsBoardId,
1003
+ overrides: options.credentialOverrides,
1004
+ print: options.print,
1005
+ force: options.force,
1006
+ });
1007
+
1008
+ // Register the MCP server up front so it's available from the very first
1009
+ // `claude` invocation (whether that's an interactive session opened right
1010
+ // after install, or the agent loop's first spawn) instead of only appearing
1011
+ // once `run`/`run --modeling` or `init-mcp` happens to run. Safe to write
1012
+ // even without a token yet — the file only ever holds the env-var
1013
+ // placeholder, never the literal secret (see connect/SKILL.md's Security notes).
1014
+ ensureMcpRegistered(targetDir, config.baseUrl || DEFAULT_BASE_URL);
1015
+ ensureEnvToken(targetDir, config.token);
1016
+ }
1000
1017
 
1001
1018
  // --- 6. Install manifest (drives precise `uninstall` later) ---
1002
1019
  // Only the footprint listed here is ever removed by `uninstall` — the root
@@ -1006,9 +1023,14 @@ async function installStack(stackKey, stackCfg, options = {}) {
1006
1023
  mkdirSync(manifestDir, { recursive: true });
1007
1024
  writeFileSync(
1008
1025
  join(manifestDir, 'install-manifest.json'),
1009
- JSON.stringify({ stack: stackKey, global: !!options.global, skills: installedSkills, claudeExtras, mcpRegistered: false }, null, 2),
1026
+ JSON.stringify({ stack: stackKey, version: CLI_VERSION, global: !!options.global, skills: installedSkills, claudeExtras, mcpRegistered: false }, null, 2),
1010
1027
  );
1011
1028
 
1029
+ // The global install scaffolds itself and then immediately starts the agent — printing
1030
+ // "Done! Start your agent:" and an init-mcp hint there would be telling the user to do
1031
+ // what this very command is already doing.
1032
+ if (options.skipEpilogue) return;
1033
+
1012
1034
  console.log('\n✅ Done! Start your agent:\n');
1013
1035
  if (isBridge) {
1014
1036
  console.log(' npx @eventmodelers/cli bridge\n');
@@ -1298,6 +1320,213 @@ function ensureEnvToken(targetDir, token) {
1298
1320
  console.log(' ✓ Wrote EVENTMODELERS_TOKEN to .claude/settings.local.json (gitignored)');
1299
1321
  }
1300
1322
 
1323
+ // --- The global modeling install (`run --global`) ------------------------------
1324
+ //
1325
+ // A modeling agent never touches the filesystem it was launched from — it works against
1326
+ // the board over MCP/REST. A project install exists only so that the `claude` process has
1327
+ // a directory with the skills in it, which is a lot of ceremony to demand of someone who
1328
+ // just wants to point an agent at a board. So there is ONE installation under
1329
+ // ~/.eventmodelers/kit, initialized on first use, and `run --standalone` falls back to it
1330
+ // whenever this directory has no kit of its own.
1331
+ //
1332
+ // One dir, not one per board: the kit is byte-for-byte identical whatever board it drives
1333
+ // (skills, CLAUDE.md, a config.js), so there is nothing in it to key per board. What IS
1334
+ // per board is the credentials, and those live in their own files beside it — see
1335
+ // boardCredentialsPath. The kit itself holds no secret at all.
1336
+ const GLOBAL_DIR = join(homedir(), '.eventmodelers');
1337
+ const GLOBAL_KIT_DIR = join(GLOBAL_DIR, 'kit');
1338
+
1339
+ // One file per board: `{token, organizationId, boardId, baseUrl, agentId}`. Credentials
1340
+ // ARE per board — a token is scoped to the org that owns it — so one machine can drive
1341
+ // several boards across several accounts at once, each with its own. Written 0600 in a
1342
+ // 0700 dir: unlike a project's .eventmodelers/config.json, there is no .gitignore standing
1343
+ // between this file and the rest of the world.
1344
+ function boardCredentialsPath(boardId) {
1345
+ return join(GLOBAL_DIR, 'boards', `${boardId}.json`);
1346
+ }
1347
+
1348
+ // Two shapes, never mixed: the board's own credentials, or a note that this board just
1349
+ // uses the account-wide ones. The second is what makes the first-run question a
1350
+ // once-per-board event instead of something to dismiss on every start.
1351
+ function writeBoardCredentials(config) {
1352
+ const path = boardCredentialsPath(config.boardId);
1353
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
1354
+ const body = config.useGlobal
1355
+ ? { boardId: config.boardId, useGlobal: true, agentId: config.agentId }
1356
+ : {
1357
+ token: config.token,
1358
+ organizationId: config.organizationId,
1359
+ boardId: config.boardId,
1360
+ baseUrl: config.baseUrl,
1361
+ agentId: config.agentId,
1362
+ };
1363
+ writeFileSync(path, JSON.stringify(body, null, 2), { mode: 0o600 });
1364
+ }
1365
+
1366
+ // The account page hands credentials over as one comma-separated blob
1367
+ // (token=...,boardId=...,organizationId=...,baseUrl=...), and an interactive prompt used to
1368
+ // be the only place that shape was accepted. --credentials takes the same blob
1369
+ // non-interactively — the JSON form works too, and '-' reads it from stdin so a token need
1370
+ // never appear in shell history or a process list. parseCredentialsPaste does the actual
1371
+ // parsing; this only turns "unparseable" into a useful error.
1372
+ function parseCredentialsArg(value) {
1373
+ const text = value === '-' ? readFileSync(0, 'utf-8') : value;
1374
+ const parsed = parseCredentialsPaste(text, ['organizationId', 'token']);
1375
+ if (!parsed) {
1376
+ console.error('❌ Could not parse --credentials. Expected the blob from https://app.eventmodelers.ai/account:');
1377
+ console.error(' token=<uuid>,boardId=<uuid>,organizationId=<uuid>,baseUrl=https://api.eventmodelers.ai');
1378
+ console.error(" The JSON form works too, and '-' reads it from stdin.");
1379
+ process.exit(1);
1380
+ }
1381
+ return parsed;
1382
+ }
1383
+
1384
+ // The account's default board, for when neither --board-id nor any config on the way up
1385
+ // named one. Same endpoint the kit's own fetchPlatformConfig calls — inlined here because
1386
+ // that module lives inside the kit we may not have initialized yet.
1387
+ async function fetchDefaultBoardId(baseUrl, token) {
1388
+ try {
1389
+ const res = await fetch(`${baseUrl}/api/config`, { headers: { 'x-token': token } });
1390
+ if (!res.ok) return null;
1391
+ return (await res.json()).boardId ?? null;
1392
+ } catch {
1393
+ return null;
1394
+ }
1395
+ }
1396
+
1397
+ // Per-run credentials for the global install. Precedence is this CLI's usual one, with the
1398
+ // per-board file slotted in as the most specific *file*: explicit flags beat
1399
+ // EVENTMODELERS_* env vars beat ~/.eventmodelers/boards/<board>.json beat the nearest
1400
+ // .eventmodelers/config.json up the tree beat ~/.eventmodelers/config.json. So
1401
+ // `run --standalone --board-id <uuid>` is enough for a board used before, and any run can
1402
+ // be pointed somewhere else entirely with --token/--organization-id.
1403
+ async function resolveModelingCredentials(cwd, flags, explicitConfigPath, print) {
1404
+ const walked = loadEffectiveConfig(cwd, null, explicitConfigPath).config;
1405
+ const explicit = Object.fromEntries(Object.entries(flags ?? {}).filter(([, v]) => v));
1406
+
1407
+ // Which board comes first — everything else is stored per board, so there is nothing to
1408
+ // look up until we know which board this run is for.
1409
+ let boardId = explicit.boardId || process.env.EVENTMODELERS_BOARD_ID || walked.boardId || null;
1410
+ let stored = boardId ? readJsonSafe(boardCredentialsPath(boardId)) : {};
1411
+
1412
+ // First time this machine has seen this board, ask the one question that can't be
1413
+ // guessed: does it get credentials of its own, or does it ride on the account-wide ones?
1414
+ // The answer is recorded either way (as credentials, or as a useGlobal marker), so this
1415
+ // is a once-per-board question rather than a prompt to dismiss on every start. Skipped
1416
+ // whenever the answer is already implied — explicit credentials on the command line — or
1417
+ // when there is no one to ask: --print, or a non-interactive stdin such as CI or a
1418
+ // supervisor that would otherwise hang here forever.
1419
+ const knownBoard = !!(stored.useGlobal || stored.token);
1420
+ if (!knownBoard && !print && !explicit.token && process.stdin.isTTY) {
1421
+ const hasAccountWide = !!(walked.token && walked.organizationId);
1422
+ const choice = await selectPrompt(
1423
+ boardId
1424
+ ? `Board ${boardId} hasn't been configured on this machine yet. Where should its credentials come from?`
1425
+ : "This board hasn't been configured on this machine yet. Where should its credentials come from?",
1426
+ [
1427
+ { label: 'The account-wide credentials (~/.eventmodelers/config.json)', value: 'global' },
1428
+ { label: 'Credentials of its own — paste them now', value: 'board' },
1429
+ ],
1430
+ hasAccountWide ? 0 : 1,
1431
+ );
1432
+
1433
+ if (choice === 'board') {
1434
+ console.log("\n Copy this board's credentials from https://app.eventmodelers.ai/account,");
1435
+ console.log(' then paste them below and press Enter:\n');
1436
+ console.log(' token=<uuid>,boardId=<uuid>,organizationId=<uuid>,baseUrl=https://api.eventmodelers.ai\n');
1437
+ const parsed = parseCredentialsPaste(await promptPasteBlock(), ['organizationId', 'token']);
1438
+ if (!parsed) {
1439
+ console.error("\n❌ Couldn't make sense of that paste — nothing was saved.");
1440
+ process.exit(1);
1441
+ }
1442
+ // The paste is the more specific answer about which board this is: someone who
1443
+ // copied board B's credentials means board B, whatever the command line defaulted to.
1444
+ if (parsed.boardId) boardId = parsed.boardId;
1445
+ stored = parsed;
1446
+ } else {
1447
+ stored = { useGlobal: true };
1448
+ }
1449
+ }
1450
+
1451
+ // applyEnvOverrides runs again here on purpose: loadEffectiveConfig already folded the
1452
+ // env layer into 'walked', and spreading the board file over that would otherwise let a
1453
+ // stored value outrank an env var the user set for this run. A useGlobal board keeps its
1454
+ // marker out of the merge — it names no credentials, it only says where to find them.
1455
+ let config = stored.useGlobal
1456
+ ? { ...applyEnvOverrides(walked), ...explicit }
1457
+ : { ...applyEnvOverrides({ ...walked, ...stored }), ...explicit };
1458
+ if (boardId) config.boardId = boardId;
1459
+
1460
+ if (!config.token || !config.organizationId) {
1461
+ // Nothing anywhere — ask once, and save it account-wide rather than into this
1462
+ // directory, so every later run from anywhere is silent.
1463
+ console.log('🔐 No Eventmodelers credentials found — configuring them once, account-wide.\n');
1464
+ config = await configureCredentials({
1465
+ config,
1466
+ configPath: join(GLOBAL_DIR, 'config.json'),
1467
+ targetDir: homedir(),
1468
+ requiredFields: ['organizationId', 'token'],
1469
+ boardIdOptional: true,
1470
+ print,
1471
+ skipGitignore: true,
1472
+ });
1473
+ }
1474
+
1475
+ if (!config.baseUrl) config.baseUrl = DEFAULT_BASE_URL;
1476
+
1477
+ if (!config.token || !config.organizationId) {
1478
+ console.error('❌ A modeling agent needs a token and an organizationId — pass --token/--organization-id, set EVENTMODELERS_TOKEN/EVENTMODELERS_ORGANIZATION_ID, or run init-config --global once.');
1479
+ process.exit(1);
1480
+ }
1481
+
1482
+ // A modeling agent always runs for exactly one board (see runModeling) — fall back to
1483
+ // the account default before giving up, since that is the board the web app opens too.
1484
+ if (!config.boardId) config.boardId = await fetchDefaultBoardId(config.baseUrl, config.token);
1485
+ if (!config.boardId) {
1486
+ console.error('❌ No board id — a modeling agent always runs for exactly one board. Pass --board-id <uuid>.');
1487
+ process.exit(1);
1488
+ }
1489
+
1490
+ // Distinguishes this agent from any other pinging the same board, and has to stay stable
1491
+ // across runs or the platform sees a brand-new agent on every restart. Per board, since
1492
+ // that is the identity the alive-ping is scoped to.
1493
+ config.agentId = stored.agentId || readJsonSafe(boardCredentialsPath(config.boardId)).agentId || randomUUID();
1494
+ writeBoardCredentials(stored.useGlobal
1495
+ ? { boardId: config.boardId, useGlobal: true, agentId: config.agentId }
1496
+ : config);
1497
+
1498
+ return config;
1499
+ }
1500
+
1501
+ // Initializes the global install if it isn't there (or was written by an older CLI) and
1502
+ // returns it, ready to be handed to runModeling as the project dir. Re-scaffolded only on
1503
+ // a version change, so the copy happens once per upgrade rather than once per run.
1504
+ async function ensureGlobalKit(baseUrl) {
1505
+ const manifestPath = join(GLOBAL_KIT_DIR, MODELING_KIT.kitDirName, '.eventmodelers', 'install-manifest.json');
1506
+
1507
+ if (readJsonSafe(manifestPath).version !== CLI_VERSION) {
1508
+ console.log(`📦 Initializing the global modeling install in ${GLOBAL_KIT_DIR}\n`);
1509
+ await installStack(MODELING_KIT.key, MODELING_KIT, {
1510
+ targetDir: GLOBAL_KIT_DIR,
1511
+ // Nothing but the kit: no root CLAUDE.md router, no .gitignore merge, no credentials
1512
+ // at rest, and no "now run this" epilogue in front of a loop about to start anyway.
1513
+ skipRootScaffold: true,
1514
+ skipCredentials: true,
1515
+ skipEpilogue: true,
1516
+ // Stands in for "yes" at the non-empty-kit-dir prompt — a re-scaffold after an
1517
+ // upgrade is precisely what we are asking for, and there is no one here to ask.
1518
+ print: true,
1519
+ });
1520
+ }
1521
+
1522
+ // Holds no secret — just the URL and a `${EVENTMODELERS_TOKEN}` placeholder, which
1523
+ // `claude` expands from the process env runModeling's spawn sets. Rewritten every run
1524
+ // because baseUrl is a per-run value here (prod vs beta), unlike in a project install.
1525
+ ensureMcpRegistered(GLOBAL_KIT_DIR, baseUrl);
1526
+
1527
+ return GLOBAL_KIT_DIR;
1528
+ }
1529
+
1301
1530
  // `run --modeling`: modeling-kit's one and only runtime mode — there is no
1302
1531
  // cold-spawn/tasks.json loop for this kit (that's a build-kit concept; see the
1303
1532
  // `run` command's build-kit-vs-modeling-kit gate above). It keeps ONE Claude
@@ -1309,7 +1538,16 @@ function ensureEnvToken(targetDir, token) {
1309
1538
  // from the kit's lib/config.js, to avoid duplicating the config-file-walk logic.
1310
1539
  // See `.agent-modeling-kit/CLAUDE.md` for the per-turn instructions this mode's
1311
1540
  // modeling session follows.
1312
- async function runModeling(kitDir, projectDir, verbose = false) {
1541
+ //
1542
+ // `standalone` adds a second, self-directed lane on top of that: the loop also
1543
+ // listens on the board's own change channel (`board:<id>` — the same one the web
1544
+ // canvas subscribes to) and, when the board goes quiet after someone edits it,
1545
+ // dispatches a turn nobody asked for, so the agent can do what a human
1546
+ // collaborator would do unprompted — fill in example data on a fresh node, post a
1547
+ // question, sketch a screen. Without the flag that channel is still subscribed on
1548
+ // the same connection and every event on it is dropped, so the two modes differ by
1549
+ // one filter rather than by a whole second realtime stack.
1550
+ async function runModeling(kitDir, projectDir, verbose = false, standalone = false, overrides = null) {
1313
1551
  const configLibPath = join(kitDir, 'lib', 'config.js');
1314
1552
  if (!existsSync(configLibPath)) {
1315
1553
  console.error(`❌ ${relative(process.cwd(), configLibPath)} not found — --modeling needs a kit installed via \`init --modeling\`.`);
@@ -1317,13 +1555,26 @@ async function runModeling(kitDir, projectDir, verbose = false) {
1317
1555
  }
1318
1556
  const { loadLocalConfig, fetchPlatformConfig } = await import(pathToFileURL(configLibPath).href);
1319
1557
 
1320
- const local = loadLocalConfig(kitDir);
1321
- local.agentId = ensureAgentId(kitDir, 'MODELING');
1558
+ // Overrides are applied twice, on purpose. Here, so the credential checks below and
1559
+ // fetchPlatformConfig's own request use the token this run was given rather than
1560
+ // whatever the config walk turned up; and again after that fetch, because it merges the
1561
+ // platform's answer OVER the local config — without which the account's default board
1562
+ // would quietly outrank an explicit --board-id.
1563
+ // The global install never inherits a config file: its credentials are resolved per
1564
+ // run (resolveModelingCredentials) and handed over whole. Walking the filesystem here
1565
+ // would also print loadLocalConfig's "no config found — platform sync disabled" note,
1566
+ // which is exactly backwards when a complete config was just passed in.
1567
+ const local = overrides ? { ...overrides } : loadLocalConfig(kitDir);
1568
+ // The global install's overrides carry their own agent id, kept per board in
1569
+ // ~/.eventmodelers/boards/<board>.json — one dir driving several boards must not have
1570
+ // them all upsert one shared alive row. A project install keeps its id in the project
1571
+ // root config, namespaced by agent type, as it always has.
1572
+ if (!overrides) local.agentId = ensureAgentId(kitDir, 'MODELING');
1322
1573
  if (!local.token || !local.organizationId) {
1323
1574
  console.error('❌ --modeling needs platform credentials in .eventmodelers/config.json (token + organizationId) — run `/connect` once or paste your config first.');
1324
1575
  process.exit(1);
1325
1576
  }
1326
- const cfg = await fetchPlatformConfig(local); // adds realtimeProvider + its provider-specific fields (supabaseUrl/supabaseAnonKey or pocketbaseUrl), + boardId if the config has a default one
1577
+ const cfg = { ...(await fetchPlatformConfig(local)), ...(overrides ?? {}) }; // adds realtimeProvider + its provider-specific fields (supabaseUrl/supabaseAnonKey or pocketbaseUrl), + boardId if the config has a default one
1327
1578
  if (!cfg.boardId) {
1328
1579
  console.error('❌ --modeling needs a boardId — a modeling agent always runs for exactly one board. Run `/connect board=<uuid>` once, or add boardId to .eventmodelers/config.json.');
1329
1580
  process.exit(1);
@@ -1343,6 +1594,15 @@ async function runModeling(kitDir, projectDir, verbose = false) {
1343
1594
  // gives the modeling session its one-time connect credentials. Every later turn
1344
1595
  // only carries the per-prompt fields that actually vary (board_id, comment_id, ...).
1345
1596
  let firstTurn = true;
1597
+ // The preamble belongs to the *session*, not to prompts: in --standalone a
1598
+ // board-change turn can just as well be the first turn a (re)spawned process
1599
+ // ever sees, so both turn builders go through this rather than buildTurn owning it.
1600
+ function withSessionHeader(body) {
1601
+ if (!firstTurn) return body;
1602
+ firstTurn = false;
1603
+ return `MODE=modeling token=${cfg.token} org=${cfg.organizationId} baseUrl=${cfg.baseUrl} standalone=${standalone ? 'on' : 'off'}\n\n${QUESTIONING_RULE}Read .agent-modeling-kit/CLAUDE.md now and follow it for every prompt in this session — it's a one-time read; don't re-read it on later turns.\n\n${body}`;
1604
+ }
1605
+
1346
1606
  function buildTurn(p) {
1347
1607
  const fields = [
1348
1608
  `prompt_id=${p.id}`,
@@ -1352,10 +1612,7 @@ async function runModeling(kitDir, projectDir, verbose = false) {
1352
1612
  p.comment_id ? `comment_id=${p.comment_id}` : null,
1353
1613
  p.node_id ? `node_id=${p.node_id}` : null,
1354
1614
  ].filter(Boolean).join(' ');
1355
- const body = `${fields}\n\n${p.prompt}`;
1356
- if (!firstTurn) return body;
1357
- firstTurn = false;
1358
- return `MODE=modeling token=${cfg.token} org=${cfg.organizationId} baseUrl=${cfg.baseUrl}\n\n${QUESTIONING_RULE}Read .agent-modeling-kit/CLAUDE.md now and follow it for every prompt in this session — it's a one-time read; don't re-read it on later turns.\n\n${body}`;
1615
+ return withSessionHeader(`${fields}\n\n${p.prompt}`);
1359
1616
  }
1360
1617
 
1361
1618
  const claudeArgs = ['--dangerously-skip-permissions', '-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose'];
@@ -1369,6 +1626,7 @@ async function runModeling(kitDir, projectDir, verbose = false) {
1369
1626
  let proc = null;
1370
1627
  let stdoutBuffer = '';
1371
1628
  let pending = null; // one in-flight turn at a time
1629
+ let lastTurnEndedAt = 0; // when the last turn finished — the standalone lane's echo window (see below)
1372
1630
 
1373
1631
  // Collapses whitespace/newlines to a single line and truncates past `max` chars —
1374
1632
  // a long multi-line curl command or grep pattern wrapped across many terminal lines
@@ -1422,6 +1680,7 @@ async function runModeling(kitDir, projectDir, verbose = false) {
1422
1680
  }
1423
1681
  if (msg.type === 'result') {
1424
1682
  log(`done (${msg.duration_ms}ms${msg.total_cost_usd ? `, $${msg.total_cost_usd.toFixed(4)}` : ''})`);
1683
+ lastTurnEndedAt = Date.now();
1425
1684
  const turn = pending;
1426
1685
  pending = null;
1427
1686
  if (turn) (msg.is_error ? turn.reject(new Error(msg.result || 'Claude turn errored')) : turn.resolve());
@@ -1439,6 +1698,7 @@ async function runModeling(kitDir, projectDir, verbose = false) {
1439
1698
  });
1440
1699
  proc.on('exit', (code) => {
1441
1700
  log(`process exited (${code}) — will respawn on next task`);
1701
+ lastTurnEndedAt = Date.now();
1442
1702
  proc = null;
1443
1703
  firstTurn = true; // a respawned process is a fresh session — needs MODE=modeling again
1444
1704
  if (pending) {
@@ -1459,6 +1719,11 @@ async function runModeling(kitDir, projectDir, verbose = false) {
1459
1719
  }
1460
1720
 
1461
1721
  spawnProcess();
1722
+ log(
1723
+ standalone
1724
+ ? 'standalone: ON — reacting to direct prompts AND to board changes on its own initiative'
1725
+ : 'standalone: off — reacting to direct prompts only (board changes are dropped)',
1726
+ );
1462
1727
 
1463
1728
  async function getRealtimeToken() {
1464
1729
  const res = await fetch(`${cfg.baseUrl}/api/org/${cfg.organizationId}/prompts/realtime-token`, {
@@ -1498,6 +1763,122 @@ async function runModeling(kitDir, projectDir, verbose = false) {
1498
1763
  }
1499
1764
  }
1500
1765
 
1766
+
1767
+ // ── Standalone lane: board changes, not just direct messages ───────────────
1768
+ //
1769
+ // `board:<id>` is the board's own change channel — the one the web canvas itself
1770
+ // subscribes to — carrying node:created/changed/deleted, edge:added/removed and
1771
+ // board:cleared with a minimal `{ type, id, node_id, user_id, seq, prev_seq }`
1772
+ // payload. It rides the realtime connection this loop already holds open for the
1773
+ // org prompt queue, so the non-standalone case joins it too and simply throws every
1774
+ // event away (see onBoardEvent). One code path either way — and whatever the backend
1775
+ // later adds to these payloads lands here without a client change.
1776
+ const BOARD_CHANGE_EVENTS = ['node:created', 'node:changed', 'node:deleted', 'edge:added', 'edge:removed', 'board:cleared'];
1777
+
1778
+ // Three guards, because a board event can't tell you who caused it: the platform
1779
+ // attributes an API token's writes to the org owner's user_id, so on this channel the
1780
+ // agent's own edits are indistinguishable from the human's.
1781
+ // DEBOUNCE — one gesture (place a node, drag a column) fans out into several
1782
+ // events; wait for the board to fall quiet, then send a single turn.
1783
+ // ECHO_WINDOW — anything arriving while a turn runs, or within this long after one
1784
+ // ends, is assumed to be that turn's own writes coming back, and dropped.
1785
+ // MIN_INTERVAL — a floor between self-directed turns, so a mistake upstream can't
1786
+ // become a self-feeding loop burning tokens unattended.
1787
+ const envMs = (name, fallback) => {
1788
+ const raw = Number(process.env[name]);
1789
+ return Number.isFinite(raw) && raw >= 0 ? raw : fallback;
1790
+ };
1791
+ const STANDALONE_DEBOUNCE_MS = envMs('EVENTMODELERS_STANDALONE_DEBOUNCE_MS', 8_000);
1792
+ const STANDALONE_ECHO_WINDOW_MS = envMs('EVENTMODELERS_STANDALONE_ECHO_WINDOW_MS', 20_000);
1793
+ const STANDALONE_MIN_INTERVAL_MS = envMs('EVENTMODELERS_STANDALONE_MIN_INTERVAL_MS', 60_000);
1794
+
1795
+ const observed = new Map(); // node_id (or '(board)') -> event types seen since the last standalone turn
1796
+ let observedCount = 0;
1797
+ let seqLo = null;
1798
+ let seqHi = null;
1799
+ let standaloneTimer = null;
1800
+ let lastStandaloneAt = 0;
1801
+
1802
+ function onBoardEvent(type, payload) {
1803
+ if (!standalone) {
1804
+ if (verbose) log(`board event ${type} dropped — not running with --standalone`);
1805
+ return;
1806
+ }
1807
+ if (pending || draining) {
1808
+ if (verbose) log(`board event ${type} dropped — a turn is in flight (assumed own write)`);
1809
+ return;
1810
+ }
1811
+ const sinceTurn = Date.now() - lastTurnEndedAt;
1812
+ if (lastTurnEndedAt && sinceTurn < STANDALONE_ECHO_WINDOW_MS) {
1813
+ if (verbose) log(`board event ${type} dropped — ${Math.round(sinceTurn / 1000)}s after a turn (assumed own write)`);
1814
+ return;
1815
+ }
1816
+ const nodeId = payload?.node_id ?? '(board)';
1817
+ if (!observed.has(nodeId)) observed.set(nodeId, new Set());
1818
+ observed.get(nodeId).add(type);
1819
+ observedCount += 1;
1820
+ const seq = Number(payload?.seq);
1821
+ if (Number.isFinite(seq)) {
1822
+ if (seqLo === null || seq < seqLo) seqLo = seq;
1823
+ if (seqHi === null || seq > seqHi) seqHi = seq;
1824
+ }
1825
+ log(`board change: ${type} node=${nodeId}${Number.isFinite(seq) ? ` seq=${seq}` : ''}`);
1826
+ armStandaloneTurn(STANDALONE_DEBOUNCE_MS);
1827
+ }
1828
+
1829
+ function armStandaloneTurn(delayMs) {
1830
+ if (standaloneTimer) clearTimeout(standaloneTimer);
1831
+ standaloneTimer = setTimeout(() => {
1832
+ standaloneTimer = null;
1833
+ dispatchStandaloneTurn().catch((err) => log(`standalone dispatch error: ${err.message}`));
1834
+ }, delayMs);
1835
+ }
1836
+
1837
+ function buildStandaloneTurn() {
1838
+ const lines = [...observed.entries()].map(([nodeId, types]) => `- ${nodeId}: ${[...types].join(', ')}`);
1839
+ const header = [
1840
+ 'BOARD_CHANGE',
1841
+ `board_id=${cfg.boardId}`,
1842
+ `organization_id=${cfg.organizationId}`,
1843
+ seqLo !== null ? `seq=${seqLo}${seqHi !== seqLo ? `..${seqHi}` : ''}` : null,
1844
+ `events=${observedCount}`,
1845
+ ].filter(Boolean).join(' ');
1846
+ return withSessionHeader(
1847
+ `${header}\nchanged:\n${lines.join('\n')}\n\n` +
1848
+ 'Nobody asked you for this — the board itself changed and you are acting on your own initiative. ' +
1849
+ 'Follow the "Standalone board-change turns" section of .agent-modeling-kit/CLAUDE.md: look at what changed, ' +
1850
+ 'decide whether there is genuinely useful modeling work to do, do at most one focused piece of it, and if ' +
1851
+ 'there is nothing worth doing, change nothing and reply <promise>NOOP</promise>.',
1852
+ );
1853
+ }
1854
+
1855
+ async function dispatchStandaloneTurn() {
1856
+ if (!observed.size) return;
1857
+ // A direct message always outranks the agent's own initiative — re-arm instead of
1858
+ // queueing behind the prompt lane, so the buffer just keeps collecting meanwhile.
1859
+ if (pending || draining) {
1860
+ armStandaloneTurn(STANDALONE_DEBOUNCE_MS);
1861
+ return;
1862
+ }
1863
+ const waitLeft = STANDALONE_MIN_INTERVAL_MS - (Date.now() - lastStandaloneAt);
1864
+ if (lastStandaloneAt && waitLeft > 0) {
1865
+ armStandaloneTurn(waitLeft);
1866
+ return;
1867
+ }
1868
+ const text = buildStandaloneTurn();
1869
+ log(`standalone turn: ${observedCount} board event(s) on ${observed.size} node(s)`);
1870
+ observed.clear();
1871
+ observedCount = 0;
1872
+ seqLo = null;
1873
+ seqHi = null;
1874
+ lastStandaloneAt = Date.now();
1875
+ try {
1876
+ await runClaudeWarm(text);
1877
+ } catch (err) {
1878
+ log(`standalone turn failed: ${err.message}`);
1879
+ }
1880
+ }
1881
+
1501
1882
  const channelName = `org:${cfg.organizationId}`;
1502
1883
  const realtime = await createRealtimeAdapter(cfg, realtimeToken);
1503
1884
 
@@ -1543,6 +1924,20 @@ async function runModeling(kitDir, projectDir, verbose = false) {
1543
1924
  log(`realtime subscribe failed, prompts won't be pushed live: ${err.message}`);
1544
1925
  });
1545
1926
 
1927
+ const boardChannelName = `board:${cfg.boardId}`;
1928
+ realtime.subscribe(
1929
+ boardChannelName,
1930
+ Object.fromEntries(BOARD_CHANGE_EVENTS.map((event) => [event, (payload) => onBoardEvent(event, payload)])),
1931
+ (status) => {
1932
+ log(`channel "${boardChannelName}": ${status}${standalone ? '' : ' (events dropped — no --standalone)'}`);
1933
+ if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') {
1934
+ refreshRealtimeToken(status).catch(() => {});
1935
+ }
1936
+ },
1937
+ ).catch((err) => {
1938
+ log(`board subscribe failed, board changes won't be seen live: ${err.message}`);
1939
+ });
1940
+
1546
1941
  setInterval(() => {
1547
1942
  refreshRealtimeToken('scheduled').catch(() => {});
1548
1943
  }, 10 * 60 * 1000);
@@ -1583,8 +1978,11 @@ program
1583
1978
  // only ever read/write an already-fetched .slices/, and report their own hint (run `fetch`
1584
1979
  // first) when that's missing. set-slice-status only touches credentials at all for --remote,
1585
1980
  // which prompts for them itself the same way fetch does. release-notes only reads the CLI's
1586
- // own bundled RELEASE_NOTES.md, no project state involved at all.
1587
- const NO_INIT_REQUIRED = new Set(['init', 'init-config', 'stacks', 'status', 'config', 'uninstall', 'fetch', 'activate-context', 'set-slice-status', 'release-notes']);
1981
+ // own bundled RELEASE_NOTES.md, no project state involved at all. run resolves its own kit
1982
+ // dir: a modeling run falls back to the global install (see ensureGlobalKit) rather than
1983
+ // requiring one here, and the build-kit branch reports a better-targeted error of its own
1984
+ // than this generic gate can.
1985
+ const NO_INIT_REQUIRED = new Set(['init', 'init-config', 'stacks', 'status', 'config', 'uninstall', 'fetch', 'activate-context', 'set-slice-status', 'release-notes', 'run']);
1588
1986
 
1589
1987
  program.hook('preAction', (_thisCommand, actionCommand) => {
1590
1988
  if (NO_INIT_REQUIRED.has(actionCommand.name())) return;
@@ -1911,11 +2309,37 @@ program
1911
2309
  credentialFlags(program
1912
2310
  .command('init-config')
1913
2311
  .description('Configure credentials only — writes .eventmodelers/config.json in the current directory, or ~/.eventmodelers/config.json with --global')
1914
- .option('--global', 'Write account-wide defaults (organizationId + token only) to ~/.eventmodelers/config.json instead of the project'))
2312
+ .option('--global', 'Write account-wide defaults (organizationId + token only) to ~/.eventmodelers/config.json instead of the project')
2313
+ .option('--credentials <values>', 'Credentials as the comma-separated blob from app.eventmodelers.ai/account (token=...,boardId=...,organizationId=...,baseUrl=...), the equivalent JSON, or - to read either from stdin. When the blob names a board it configures THAT board (~/.eventmodelers/boards/<board>.json), which is all a later run --standalone --board-id <uuid> then needs.'))
1915
2314
  .action(async (opts, command) => {
1916
2315
  const globalOpts = command.optsWithGlobals();
1917
2316
  const overrides = credentialOverridesFromOpts(opts);
1918
2317
 
2318
+ // A blob naming a board configures that board's own file rather than a project or
2319
+ // account-wide config: the per-board store is keyed by board id, and the blob is
2320
+ // carrying one. --global still means account-wide identity only, and without
2321
+ // --credentials nothing here changes, so no existing invocation behaves differently.
2322
+ if (opts.credentials && !opts.global) {
2323
+ const parsed = {
2324
+ ...parseCredentialsArg(opts.credentials),
2325
+ ...Object.fromEntries(Object.entries(overrides).filter(([, v]) => v)),
2326
+ };
2327
+ if (!parsed.boardId) {
2328
+ console.error('❌ --credentials names no board — add boardId=<uuid> to it, or pass --board-id, so we know which board this configures.');
2329
+ console.error(' (For account-wide identity with no board, use --global.)');
2330
+ process.exit(1);
2331
+ }
2332
+ if (!parsed.baseUrl) parsed.baseUrl = DEFAULT_BASE_URL;
2333
+ // Preserved across re-configuration: the platform keys a board's alive-ping on it, so
2334
+ // regenerating it would present a long-running agent as a brand-new one.
2335
+ parsed.agentId = readJsonSafe(boardCredentialsPath(parsed.boardId)).agentId || randomUUID();
2336
+ writeBoardCredentials(parsed);
2337
+ console.log('\n ✓ Saved credentials for board ' + parsed.boardId + ' to ' + boardCredentialsPath(parsed.boardId));
2338
+ console.log('\n Start the agent from anywhere with:\n');
2339
+ console.log(' npx @eventmodelers/cli run --standalone --board-id ' + parsed.boardId + '\n');
2340
+ return;
2341
+ }
2342
+
1919
2343
  if (opts.global) {
1920
2344
  // Deliberately narrower than a project config: a board is specific to one
1921
2345
  // project, and baseUrl already has its own runtime default, so the only
@@ -1924,7 +2348,12 @@ credentialFlags(program
1924
2348
  const configPath = join(homedir(), '.eventmodelers', 'config.json');
1925
2349
  const requiredFields = ['organizationId', 'token'];
1926
2350
  const existing = readJsonSafe(configPath);
2351
+ const pasted = opts.credentials ? parseCredentialsArg(opts.credentials) : {};
1927
2352
  const base = { organizationId: existing.organizationId, token: existing.token };
2353
+ // Any boardId/baseUrl in the blob is dropped here, exactly as the interactive paste
2354
+ // flow's own result is below — --global persists identity and nothing else.
2355
+ if (pasted.organizationId) base.organizationId = pasted.organizationId;
2356
+ if (pasted.token) base.token = pasted.token;
1928
2357
  if (overrides.organizationId) base.organizationId = overrides.organizationId;
1929
2358
  if (overrides.token) base.token = overrides.token;
1930
2359
 
@@ -1937,7 +2366,11 @@ credentialFlags(program
1937
2366
  overrides: {},
1938
2367
  print: globalOpts.print,
1939
2368
  skipGitignore: true,
1940
- force: true,
2369
+ // A bare "init-config --global" means "re-ask me", so it forces the prompt even
2370
+ // when the config is already complete. Supplying --credentials (or --token /
2371
+ // --organization-id) is the opposite instruction: the answer is right there on the
2372
+ // command line, and prompting for it anyway would hang any non-interactive caller.
2373
+ force: !(base.organizationId && base.token),
1941
2374
  });
1942
2375
 
1943
2376
  // configureCredentials' generic paste/manual flow may have picked up
@@ -1971,15 +2404,19 @@ credentialFlags(program
1971
2404
  }
1972
2405
  });
1973
2406
 
1974
- program
2407
+ credentialFlags(program
1975
2408
  .command('run')
1976
- .description('Start the agent loop from the installed kit dir — build-kit stacks: ralph-claude.js (default); modeling-kit: requires --modeling')
2409
+ .description('Start the agent loop from the installed kit dir — build-kit stacks: ralph-claude.js (default); modeling-kit: --modeling, or --standalone, which needs no install at all')
1977
2410
  .option('--ollama', 'Use ralph-ollama.js instead of the default Claude runner (build-kit stacks only)')
1978
2411
  .option('--bash', 'Use the bash-only ralph.sh loop (build-kit stacks only, no realtime)')
1979
- .option('--modeling', 'Keep one Claude process warm across prompts instead of spawning a fresh one per task, for low-latency voice/live use. Modeling-kit installs only there is no cold-spawn/tasks.json loop for modeling-kit. Built into the CLI, not a per-project file.')
2412
+ .option('--modeling', 'Keep one Claude process warm across prompts instead of spawning a fresh one per task, for low-latency voice/live use. Runs from a modeling-kit install in this directory, or from the global install (~/.eventmodelers/kit) when there is none. Built into the CLI, not a per-project file.')
2413
+ .option('--standalone', 'Let the modeling agent act on its own initiative: on top of direct prompts it subscribes to the board\'s change channel (like the build agents do) and, whenever the board goes quiet after an edit, decides for itself what a human collaborator would do next — fill in examples on a new node, post a comment, sketch a screen. Implies --modeling.')
2414
+ .option('--global', 'Run the modeling agent from the global install (~/.eventmodelers/kit), initializing it on first use, and ignore any kit in this directory. This is also what --modeling/--standalone fall back to on their own when nothing is installed here — pass it explicitly to prefer the global install over a local one. Credentials come from the flags below, EVENTMODELERS_* env vars, or ~/.eventmodelers/boards/<board>.json, so nothing is written into the current directory.')
1980
2415
  .option('--local', 'Skip platform config/credential lookup entirely and run the local-only loop (no board sync, no realtime agent) — even if .eventmodelers/config.json has credentials (build-kit stacks only)')
1981
2416
  .option('--verbose', 'Log every tool call\'s full input (commands, skill args, file paths) and assistant reasoning text. Default is condensed, high-level per-step logging only.')
1982
- .action(async (opts) => {
2417
+ .option('--credentials <values>', 'Credentials as the comma-separated blob from app.eventmodelers.ai/account (token=...,boardId=...,organizationId=...,baseUrl=...), the equivalent JSON, or - to read either from stdin. Saved to ~/.eventmodelers/boards/<board>.json, so it is only needed once per board, and passing it skips the first-run question. The individual flags below override single fields of it.'))
2418
+ .action(async (opts, command) => {
2419
+ const globalOpts = command.optsWithGlobals();
1983
2420
  const cwd = process.cwd();
1984
2421
  // Both kit dirs can be installed side by side (e.g. running a build-kit and a
1985
2422
  // modeling-kit agent from the same project). findInstalledKitDir only ever
@@ -2000,27 +2437,51 @@ program
2000
2437
  // cold-spawn/tasks.json loop (default, or --ollama/--bash). Neither falls back
2001
2438
  // to the other's mechanism, so each side is gated explicitly below rather than
2002
2439
  // just being left to fail on a missing file.
2003
- if (opts.modeling) {
2440
+ // --standalone implies --modeling: it already refused every other runner, so there
2441
+ // was never a second thing it could have selected, and requiring both flags only made
2442
+ // the shorter, more obvious command fail. --global picks the modeling loop too — it has
2443
+ // no meaning for a build kit, which is scaffolded per project by definition.
2444
+ if (opts.modeling || opts.standalone || opts.global) {
2445
+ const picked = opts.modeling ? '--modeling' : opts.standalone ? '--standalone' : '--global';
2004
2446
  if (opts.bash || opts.ollama) {
2005
- console.error('❌ --modeling is mutually exclusive with --bash/--ollama — those select a build-kit runner, which --modeling has no use for.');
2447
+ console.error(`❌ ${picked} is mutually exclusive with --bash/--ollama — those select a build-kit runner, which the modeling loop has no use for.`);
2006
2448
  process.exit(1);
2007
2449
  }
2008
2450
  if (opts.local) {
2009
- console.error('❌ --modeling has no local-only mode — it is always driven by the org-wide realtime prompt queue, so --local has no use for it.');
2451
+ console.error(`❌ ${picked} has no local-only mode — it is always driven by the org-wide realtime prompt queue, so --local has no use for it.`);
2010
2452
  process.exit(1);
2011
2453
  }
2012
- if (!modelingKitDir) {
2013
- console.error(`❌ --modeling only supports a modeling-kit install (${MODELING_KIT.kitDirName}/) it subscribes to the org-wide prompt queue, which build-kit stacks don't have. Use \`eventmodelers run\` (optionally with --ollama/--bash) for build-kit's slice-status loop instead.`);
2014
- process.exit(1);
2454
+
2455
+ // A kit in this directory wins unless --global explicitly asks for the other one.
2456
+ // Otherwise: the global install, initialized on first use, driven by this run's own
2457
+ // credentials resolved from flags/env/~/.eventmodelers — so the current directory is
2458
+ // neither read nor written, and the command works from anywhere.
2459
+ let kitDir = opts.global ? null : modelingKitDir;
2460
+ let projectDir = kitDir ? resolve(kitDir, '..') : null;
2461
+ let overrides = null;
2462
+ if (!kitDir) {
2463
+ // The blob and the individual flags are both "explicit", so they share a
2464
+ // precedence tier — with a single --token/--board-id winning, since overriding one
2465
+ // field of a pasted blob is the only reason to pass both.
2466
+ const flags = {
2467
+ ...(opts.credentials ? parseCredentialsArg(opts.credentials) : {}),
2468
+ ...Object.fromEntries(Object.entries(credentialOverridesFromOpts(opts)).filter(([, v]) => v)),
2469
+ };
2470
+ const config = await resolveModelingCredentials(cwd, flags, globalOpts.config, globalOpts.print);
2471
+ projectDir = await ensureGlobalKit(config.baseUrl);
2472
+ kitDir = join(projectDir, MODELING_KIT.kitDirName);
2473
+ overrides = config;
2015
2474
  }
2475
+
2016
2476
  // Writes to a stdout pipe are asynchronous on POSIX — without waiting for this
2017
- // write's own flush callback, the heavier synchronous/async work runModeling()
2018
- // does right after (dynamic imports, config reads) can eat the event-loop tick
2019
- // this write needed to drain, so a piped watcher sees the ping arrive after
2020
- // runModeling's own [modeling] log lines instead of before them.
2021
- await new Promise((res) => process.stdout.write(`▶ Starting modeling loop (warm Claude process) for ${relative(cwd, modelingKitDir)}...\n\n`, res));
2477
+ // write's own flush callback, the heavier synchronous/async work runModeling() does
2478
+ // right after (dynamic imports, config reads) can eat the event-loop tick this write
2479
+ // needed to drain, so a piped watcher sees the ping arrive after runModeling's own
2480
+ // [modeling] log lines instead of before them.
2481
+ const shown = relative(cwd, kitDir);
2482
+ await new Promise((res) => process.stdout.write(`▶ Starting modeling loop (warm Claude process) for ${shown && !shown.startsWith('..') ? shown : kitDir}...\n\n`, res));
2022
2483
  try {
2023
- await runModeling(modelingKitDir, resolve(modelingKitDir, '..'), !!opts.verbose);
2484
+ await runModeling(kitDir, projectDir, !!opts.verbose, !!opts.standalone, overrides);
2024
2485
  } catch (err) {
2025
2486
  console.error('[modeling] Fatal:', err);
2026
2487
  process.exit(1);
@@ -2035,6 +2496,7 @@ program
2035
2496
  console.error(`❌ A bridge-kit install (${BRIDGE_KIT.kitDirName}/) only runs via \`eventmodelers bridge\` — it has no --modeling/--ollama/--bash modes.`);
2036
2497
  } else {
2037
2498
  console.error(`❌ No kit installed in ${cwd} — run \`eventmodelers install\` first.`);
2499
+ console.error(' (A modeling agent needs no install at all: eventmodelers run --standalone --board-id <uuid>)');
2038
2500
  }
2039
2501
  process.exit(1);
2040
2502
  }