@eventmodelers/cli 1.0.95 → 1.0.97
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 +27 -3
- package/cli.js +114 -16
- package/lib/modeling-exec.js +164 -0
- package/package.json +1 -1
- package/shared/build-kit/README.md +11 -1
package/README.md
CHANGED
|
@@ -128,7 +128,8 @@ npx @eventmodelers/cli init --stack <name> --demo # same, plus a ready-made de
|
|
|
128
128
|
npx @eventmodelers/cli re-init # refresh an already-installed kit's scripts/skills only — never touches the root scaffold
|
|
129
129
|
npx @eventmodelers/cli run # start the agent loop (ralph-claude.js) from the installed kit dir
|
|
130
130
|
npx @eventmodelers/cli run --local-ai [target] # board work only via a local/self-hosted model (ralph-local-ai.js) — does NOT build slices; build locally with --exec
|
|
131
|
-
npx @eventmodelers/cli run --
|
|
131
|
+
npx @eventmodelers/cli run --agent <name> # pick the agent: claude (default), opencode, codex, gemini — bare --agent lists them with their exact commands
|
|
132
|
+
npx @eventmodelers/cli run --exec "<command>" # manual fallback for any other harness (ralph-exec.js) — agent tracing needs its JSON flag, e.g. "codex exec --json --full-auto"
|
|
132
133
|
npx @eventmodelers/cli run --bash # bash-only loop, no realtime (ralph.sh)
|
|
133
134
|
npx @eventmodelers/cli run --local # skip platform config/credential lookup entirely — local-only, no board sync
|
|
134
135
|
npx @eventmodelers/cli run --modeling # modeling-kit: warm Claude process driven by the board's prompt queue
|
|
@@ -191,8 +192,31 @@ npx @eventmodelers/cli run --modeling # react to prompts sent to t
|
|
|
191
192
|
npx @eventmodelers/cli run --standalone # …and to board changes, on its own initiative
|
|
192
193
|
```
|
|
193
194
|
|
|
194
|
-
`--standalone` implies `--modeling`, so you never need both.
|
|
195
|
-
|
|
195
|
+
`--standalone` implies `--modeling`, so you never need both.
|
|
196
|
+
|
|
197
|
+
**Another coding agent.** `--agent` hands each prompt and chat message to another harness
|
|
198
|
+
instead of the warm Claude process — one process per turn, with the eventmodelers MCP server
|
|
199
|
+
registered for it (`opencode.json`, `.gemini/settings.json`, or `-c` flags for Codex):
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
npx @eventmodelers/cli run --standalone --agent opencode # = --exec "opencode run"
|
|
203
|
+
npx @eventmodelers/cli run --modeling --agent codex # = --exec "codex exec --full-auto"
|
|
204
|
+
npx @eventmodelers/cli run --modeling --agent gemini # = --exec "gemini --yolo -p"
|
|
205
|
+
npx @eventmodelers/cli run --modeling --agent claude # the default, spelled out
|
|
206
|
+
npx @eventmodelers/cli run --agent # list the agents and the command each runs
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
`--agent` is only a shortcut: to run a harness it does not know, or the same one with other
|
|
210
|
+
flags, pass the command yourself with `--exec "<command>"`. Keep the harness in its plain-text
|
|
211
|
+
output mode — its stdout is the turn's answer. For an unknown command the loop logs the MCP
|
|
212
|
+
server to register by hand (`<baseUrl>/mcp`, header `x-token` from `$EVENTMODELERS_TOKEN`).
|
|
213
|
+
Run `init-agents --hosts <harness>` to install the skills for a harness that does not read
|
|
214
|
+
`.claude/skills`. There are no subagents: self-directed work is done inline.
|
|
215
|
+
|
|
216
|
+
`--agent`, `--exec`, `--local-ai` and `--bash` each pick the runner, so pass at most one of them —
|
|
217
|
+
any two together are rejected. For a local or self-hosted model use `--local-ai`, not `--agent`.
|
|
218
|
+
|
|
219
|
+
To drive either one with a local model instead of Claude, see [Running the modeling agent on a local model](#running-the-modeling-agent-on-a-local-model).
|
|
196
220
|
|
|
197
221
|
**No install required.** A modeling agent never touches the directory it was started from —
|
|
198
222
|
it works against the board over MCP/REST — so it doesn't need a kit scaffolded there. When
|
package/cli.js
CHANGED
|
@@ -67,6 +67,55 @@ function resolveLocalAiTarget(opts) {
|
|
|
67
67
|
return raw;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
// --- --agent: named shortcuts for --exec ---------------------------------------
|
|
71
|
+
// `run --agent opencode` is exactly `run --exec "<preset>"` — the harness command spelled out
|
|
72
|
+
// once here, so nobody has to remember its headless flags. The two loops want different output
|
|
73
|
+
// from the same harness: the modeling loop reads a turn's answer from plain-text stdout, while a
|
|
74
|
+
// build kit wants the JSON mode, the only one that reports the usage agent tracing records.
|
|
75
|
+
// claude has no command — it is the default runner, named so it can be picked explicitly.
|
|
76
|
+
// --exec stays the fallback for any harness or flag set not listed here.
|
|
77
|
+
const AGENT_PRESETS = {
|
|
78
|
+
claude: null,
|
|
79
|
+
opencode: { modeling: 'opencode run', build: 'opencode run --auto --format json' },
|
|
80
|
+
codex: { modeling: 'codex exec --full-auto', build: 'codex exec --json --full-auto' },
|
|
81
|
+
gemini: { modeling: 'gemini --yolo -p', build: 'gemini --yolo --output-format stream-json -p' },
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
function printAgentPresets() {
|
|
85
|
+
console.log('Supported agents for run --agent <name>:\n');
|
|
86
|
+
for (const [name, preset] of Object.entries(AGENT_PRESETS)) {
|
|
87
|
+
console.log(` ${name.padEnd(9)}${preset ? `modeling: ${preset.modeling}\n ${''.padEnd(9)}build: ${preset.build}` : 'the default runner (warm Claude process / ralph-claude.js)'}`);
|
|
88
|
+
}
|
|
89
|
+
console.log('\nAny other harness: run --exec "<command>".');
|
|
90
|
+
console.log(`A local or self-hosted model: run --local-ai [${LOCAL_AI_TARGETS.join('|')}].`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Resolves --agent into the --exec command it stands for, or null for the default Claude runner.
|
|
94
|
+
// Bare --agent lists the presets and stops.
|
|
95
|
+
function resolveAgentPreset(opts, loop) {
|
|
96
|
+
if (opts.agent === undefined) return null;
|
|
97
|
+
if (opts.agent === true) {
|
|
98
|
+
printAgentPresets();
|
|
99
|
+
process.exit(0);
|
|
100
|
+
}
|
|
101
|
+
const name = String(opts.agent).toLowerCase();
|
|
102
|
+
if (!(name in AGENT_PRESETS)) {
|
|
103
|
+
console.error(`❌ Unknown --agent "${opts.agent}" — one of: ${Object.keys(AGENT_PRESETS).join(', ')}.`);
|
|
104
|
+
console.error(' Any other harness works via --exec "<command>".');
|
|
105
|
+
console.error(` A local or self-hosted model works via --local-ai [${LOCAL_AI_TARGETS.join('|')}].`);
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
108
|
+
if (opts.exec) {
|
|
109
|
+
console.error('❌ --agent and --exec are mutually exclusive — --agent is a shortcut for --exec.');
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
if (resolveLocalAiTarget(opts) !== null || opts.bash) {
|
|
113
|
+
console.error('❌ --agent is mutually exclusive with --local-ai/--bash — pick one runner.');
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
return AGENT_PRESETS[name]?.[loop] ?? null;
|
|
117
|
+
}
|
|
118
|
+
|
|
70
119
|
const STACKS = {
|
|
71
120
|
node: {
|
|
72
121
|
label: 'Node.js / TypeScript',
|
|
@@ -1867,7 +1916,7 @@ async function ensureGlobalKit(baseUrl) {
|
|
|
1867
1916
|
// untargeted is handed straight back to the queue for another agent to take. It says
|
|
1868
1917
|
// nothing about the standalone lane — a self-directed turn is nobody's task, so an
|
|
1869
1918
|
// exclusive standalone agent still works the board on its own initiative.
|
|
1870
|
-
async function runModeling(kitDir, projectDir, { verbose = false, standalone = false, exclusive = false, worker = false, overrides = null, maxAgents = DEFAULT_MAX_AGENTS, identity = {}, localAi = null } = {}) {
|
|
1919
|
+
async function runModeling(kitDir, projectDir, { verbose = false, standalone = false, exclusive = false, worker = false, overrides = null, maxAgents = DEFAULT_MAX_AGENTS, identity = {}, localAi = null, exec = null } = {}) {
|
|
1871
1920
|
// --worker: only prompts — no chat, and no board-change turns (it has nowhere to propose).
|
|
1872
1921
|
const chat = !worker;
|
|
1873
1922
|
const configLibPath = join(kitDir, 'lib', 'config.js');
|
|
@@ -1934,6 +1983,24 @@ async function runModeling(kitDir, projectDir, { verbose = false, standalone = f
|
|
|
1934
1983
|
process.exit(1);
|
|
1935
1984
|
}
|
|
1936
1985
|
}
|
|
1986
|
+
// --exec: any agent harness, one process per turn (lib/modeling-exec.js). Unlike a local model
|
|
1987
|
+
// it brings its own tool loop and reads the kit — CLAUDE.md and whatever skills `init-agents`
|
|
1988
|
+
// installed for it — so its turns keep the session header; what it lacks is the Agent tool, so
|
|
1989
|
+
// they drop the fan-out like a local model's do. Imported only here: its turn supervisor
|
|
1990
|
+
// installs signal handlers on import, which no other command should get.
|
|
1991
|
+
let execRunner = null;
|
|
1992
|
+
if (exec) {
|
|
1993
|
+
const { createModelingExecRunner } = await import('./lib/modeling-exec.js');
|
|
1994
|
+
try {
|
|
1995
|
+
execRunner = createModelingExecRunner({ cfg, command: exec === true ? cfg.localAi?.exec : exec, projectDir, log });
|
|
1996
|
+
} catch (err) {
|
|
1997
|
+
console.error(`❌ --exec: ${err.message}`);
|
|
1998
|
+
process.exit(1);
|
|
1999
|
+
}
|
|
2000
|
+
}
|
|
2001
|
+
// The runner that is not the warm `claude` process, if any — for the few places that only care
|
|
2002
|
+
// whether one is.
|
|
2003
|
+
const otherRunner = localRunner ?? execRunner;
|
|
1937
2004
|
|
|
1938
2005
|
const QUESTIONING_RULE =
|
|
1939
2006
|
'IMPORTANT: You are running autonomously — no human is available to answer questions. ' +
|
|
@@ -1944,6 +2011,17 @@ async function runModeling(kitDir, projectDir, { verbose = false, standalone = f
|
|
|
1944
2011
|
'(see "Chat turns" in .agent-modeling-kit/CLAUDE.md), and never by pausing the session. ' +
|
|
1945
2012
|
'A chat message is at most 1000 characters.\n\n';
|
|
1946
2013
|
|
|
2014
|
+
// An exec turn is a fresh process on every turn, so the kit's connect step would run — and
|
|
2015
|
+
// re-verify the same credentials — once per message. It has nothing left to do: registerMcp
|
|
2016
|
+
// (lib/modeling-exec.js) put the eventmodelers server into the harness's config before the
|
|
2017
|
+
// first turn, authenticated from the env this process hands it. The credentials in the header
|
|
2018
|
+
// stay, for the skills' REST fallback.
|
|
2019
|
+
const EXEC_CONNECTED =
|
|
2020
|
+
'The eventmodelers MCP server is already registered and authenticated for you, so you are connected: ' +
|
|
2021
|
+
'do NOT run /connect and do not check any MCP config file — wherever the kit or a skill says to connect ' +
|
|
2022
|
+
'first, that is already done. Use the eventmodelers tools directly, with the board_id of this turn. The ' +
|
|
2023
|
+
'credentials above are only for a REST fallback, should a tool you need be missing.\n\n';
|
|
2024
|
+
|
|
1947
2025
|
// Sent once, on the first turn only — it's what tells the agent to follow
|
|
1948
2026
|
// .agent-modeling-kit/CLAUDE.md's per-turn steps for this warm session (instead
|
|
1949
2027
|
// of the root router's default of reading every installed kit's CLAUDE.md), and
|
|
@@ -1961,9 +2039,11 @@ async function runModeling(kitDir, projectDir, { verbose = false, standalone = f
|
|
|
1961
2039
|
// (its tools are already authenticated), and handing it the raw token would put credentials
|
|
1962
2040
|
// in a context that has no way to use them.
|
|
1963
2041
|
if (localRunner) return body;
|
|
1964
|
-
|
|
2042
|
+
// Every exec turn is a fresh process — a new session that has read nothing, so each one gets
|
|
2043
|
+
// the header its first turn would.
|
|
2044
|
+
if (!firstTurn && !execRunner) return body;
|
|
1965
2045
|
firstTurn = false;
|
|
1966
|
-
return `MODE=modeling token=$EVENTMODELERS_TOKEN org=${cfg.organizationId} baseUrl=${cfg.baseUrl} standalone=${standalone ? 'on' : 'off'}${standalone ? ` max_agents=${maxAgents}` : ''} subagent_model=${subagentModel}\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}`;
|
|
2046
|
+
return `MODE=modeling token=$EVENTMODELERS_TOKEN org=${cfg.organizationId} baseUrl=${cfg.baseUrl} standalone=${standalone ? 'on' : 'off'}${standalone ? ` max_agents=${maxAgents}` : ''} subagent_model=${subagentModel}\n\n${QUESTIONING_RULE}${execRunner?.mcpRegistered ? EXEC_CONNECTED : ''}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}`;
|
|
1967
2047
|
}
|
|
1968
2048
|
|
|
1969
2049
|
function buildTurn(p) {
|
|
@@ -2174,18 +2254,19 @@ async function runModeling(kitDir, projectDir, { verbose = false, standalone = f
|
|
|
2174
2254
|
// The one thing that knew a `claude` process was behind a turn. Both lanes — the prompt
|
|
2175
2255
|
// queue (drain) and the self-directed one (dispatchStandaloneTurn) — go through here.
|
|
2176
2256
|
async function runTurn(text) {
|
|
2177
|
-
if (
|
|
2257
|
+
if (otherRunner) return otherRunner.runTurn(text);
|
|
2178
2258
|
if (!proc) spawnProcess();
|
|
2179
2259
|
await warmUpSession();
|
|
2180
2260
|
return sendTurn(text);
|
|
2181
2261
|
}
|
|
2182
2262
|
|
|
2183
|
-
if (!
|
|
2263
|
+
if (!otherRunner) spawnProcess();
|
|
2184
2264
|
log(`agent: ${cfg.agentName ? `${cfg.agentName} (${cfg.agentId})` : cfg.agentId}`);
|
|
2185
2265
|
if (localRunner) log(`runner: local model — ${localRunner.describe()} (board tools over MCP; no skills, no subagents)`);
|
|
2266
|
+
if (execRunner) log(`runner: ${execRunner.describe()} — one process per turn (skills from the kit; no subagents)`);
|
|
2186
2267
|
log(
|
|
2187
2268
|
standalone
|
|
2188
|
-
? `standalone: ON — reacting to direct prompts AND to board changes on its own initiative (${localRunner ? 'work is done inline — a local model has no subagents' : `max ${maxAgents} subagent(s) per self-directed turn`})`
|
|
2269
|
+
? `standalone: ON — reacting to direct prompts AND to board changes on its own initiative (${localRunner ? 'work is done inline — a local model has no subagents' : execRunner ? 'work is done inline — an exec agent has no subagents' : `max ${maxAgents} subagent(s) per self-directed turn`})`
|
|
2189
2270
|
: worker
|
|
2190
2271
|
? 'worker: ON — working prompts only (no chat, board changes are dropped)'
|
|
2191
2272
|
: 'standalone: off — board changes get a proposal in the chat, nothing is changed without a yes',
|
|
@@ -2201,8 +2282,10 @@ async function runModeling(kitDir, projectDir, { verbose = false, standalone = f
|
|
|
2201
2282
|
// Both warm-ups are the same bet — pay the session's fixed setup cost before a turn arrives
|
|
2202
2283
|
// rather than making whoever sends the first prompt wait for it. For a local model that cost
|
|
2203
2284
|
// is the MCP tool set; for Claude it is reading CLAUDE.md and running /connect.
|
|
2285
|
+
// An exec runner has nothing to warm: every turn is its own process, so a warm-up would connect
|
|
2286
|
+
// a session that exits right after.
|
|
2204
2287
|
if (localRunner) localRunner.warmUp().catch((err) => log(`local-ai warm-up failed (the first turn will retry): ${err.message}`));
|
|
2205
|
-
else warmUpSession();
|
|
2288
|
+
else if (!execRunner) warmUpSession();
|
|
2206
2289
|
|
|
2207
2290
|
async function getRealtimeToken() {
|
|
2208
2291
|
const res = await fetch(`${cfg.baseUrl}/api/org/${cfg.organizationId}/prompts/realtime-token`, {
|
|
@@ -2580,6 +2663,9 @@ async function runModeling(kitDir, projectDir, { verbose = false, standalone = f
|
|
|
2580
2663
|
const AGENT_BUDGET = localRunner
|
|
2581
2664
|
? 'You have no subagents and no skills here — the board tools are all you have. Do the single most ' +
|
|
2582
2665
|
'valuable piece of work yourself, inline, in this turn, and leave the rest for a later turn.'
|
|
2666
|
+
: execRunner
|
|
2667
|
+
? 'You have no subagents here — wherever the kit says to dispatch an Agent, do that work yourself instead. ' +
|
|
2668
|
+
'Do the single most valuable piece of work yourself, inline, in this turn, and leave the rest for a later turn.'
|
|
2583
2669
|
: maxAgents > 1
|
|
2584
2670
|
? `Dispatch at most ${maxAgents} Agents in this turn (--max-agents=${maxAgents}). Merge pieces that share a slice or ` +
|
|
2585
2671
|
'chain first — that is a correctness rule, not a way to fit the cap — and if more than that is still left, ' +
|
|
@@ -2595,6 +2681,9 @@ async function runModeling(kitDir, projectDir, { verbose = false, standalone = f
|
|
|
2595
2681
|
// from disk; a local model has neither, so it just works the board directly.
|
|
2596
2682
|
const FAN_OUT = localRunner
|
|
2597
2683
|
? `get the most valuable piece of it done with the board tools in this turn. ${AGENT_BUDGET}`
|
|
2684
|
+
: execRunner
|
|
2685
|
+
? `get the most valuable piece of it done in this turn. ${AGENT_BUDGET} Read ` +
|
|
2686
|
+
'.agent-modeling-kit/CLAUDE-STANDALONE.md now and follow it: it holds the steps for this kind of turn, and only this kind.'
|
|
2598
2687
|
: 'work in parallel rather than serially — dispatch one Agent per piece of ' +
|
|
2599
2688
|
'work that needs doing, all in a single message, merging pieces that share a slice or chain so no two ' +
|
|
2600
2689
|
`agents write to the same area. ${AGENT_BUDGET} Read .agent-modeling-kit/CLAUDE-STANDALONE.md now (once ` +
|
|
@@ -3473,9 +3562,10 @@ credentialFlags(program
|
|
|
3473
3562
|
.command('run')
|
|
3474
3563
|
.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')
|
|
3475
3564
|
.option('--local-ai [target]', `Drive the loop with a local (or self-hosted) model instead of the default Claude runner: a build kit runs it via ralph-local-ai.js, and --modeling/--standalone via lib/modeling-local-ai.js (board tools over MCP, but no skills and no subagent fan-out — those are Claude Code features). Optional target preset picks the URL and wire dialect: ${LOCAL_AI_TARGETS.join(', ')} — bare --local-ai means ollama. Anything OpenAI-compatible (vLLM, LM Studio, llama.cpp, TGI) works by pointing LOCAL_AI_URL at it; see LOCAL_AI_* in the docs. Board work only: --local-ai never builds a Planned slice (no file, shell or git tools). To build code with a local model, use --exec with a coding harness pointed at it, e.g. "codex exec --oss --full-auto". Claude remains the default when this flag is absent.`)
|
|
3476
|
-
.option('--
|
|
3565
|
+
.option('--agent [name]', `Pick the agent that does the work by name: ${Object.keys(AGENT_PRESETS).join(', ')}. claude is the default runner; any other is a shortcut for --exec with that harness's headless command filled in — plain-text output for --modeling/--standalone/--worker, JSON output (for agent tracing) for a build kit. Bare --agent lists the agents and the exact command each runs, then stops. Mutually exclusive with --exec, --local-ai and --bash.`)
|
|
3566
|
+
.option('--exec [command]', 'Fallback for any harness --agent does not cover, or other flags than its preset. Hand each prompt to an external agent command instead of the default Claude runner — for agentic harnesses that bring their own tool loop, e.g. "codex exec --full-auto", "gemini --yolo -p" or "opencode run". One process per prompt; the prompt is appended as a quoted argument and also written to the file named by RALPH_PROMPT_FILE. Bare --exec uses localAi.exec from .eventmodelers/config.json. Claude remains the default when this flag is absent. A build kit runs it via ralph-exec.js; there, with agent tracing on, include the harness\'s JSON output flag (codex exec --json, opencode run --format json, gemini --output-format stream-json) — without it no slice cost is recorded. With --modeling/--standalone/--worker it is how any agent takes board prompts and chats: the loop claims each prompt or chat message and runs the command once for it. Keep the harness in its plain-text output mode there — stdout is the turn\'s answer — the eventmodelers MCP server is registered for it on every run (opencode.json, .gemini/settings.json, or -c flags for codex; any other command logs what to register by hand), authenticating with $EVENTMODELERS_TOKEN from the env the command inherits, and `init-agents --hosts <harness>` installs the skills for a harness that does not read .claude/skills. No subagents: self-directed work is done inline.')
|
|
3477
3567
|
.option('--bash', 'Use the bash-only ralph.sh loop (build-kit stacks only, no realtime)')
|
|
3478
|
-
.option('--modeling', '
|
|
3568
|
+
.option('--modeling', 'Connect a modeling agent to the board: it takes prompts and chat messages and hands each to an AI — by default one Claude process kept warm across prompts, for low-latency voice/live use; --agent (or --local-ai, or --exec as the manual fallback) picks another. 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.')
|
|
3479
3569
|
.option('--non-interactive', 'Never ask anything: take the board and credentials that resolve from flags, EVENTMODELERS_* env vars and the config files, and fail with the reason if they are incomplete instead of interviewing for them. A TTY was previously the only signal — right for CI or a supervisor, wrong for a loop started from a terminal, where stdin is a TTY nobody is watching and the run stops on a question. Only affects the modeling loop (--modeling/--standalone/--worker/--global).')
|
|
3480
3570
|
.option('--standalone', 'Let the modeling agent act on board changes on its own initiative, without asking: it subscribes to the board\'s change channel and, whenever the board goes quiet after an edit — or has simply been idle for a while — it takes a turn nobody asked for, judges the model as a whole and fans the work out over parallel subagents, one per changed area (examples on a new node, specs for a new command or read model, a missing attribute along a chain, a screen, a question). Filling that detail in while the human keeps modeling is the point. A plain --modeling agent only proposes, and only to the people chatting with it: after their edits it says in the chat what it would do and waits for a yes; everyone else\'s edits are ignored. Implies --modeling.')
|
|
3481
3571
|
.option('--max-agents <n>', 'Cap how many subagents a self-directed --standalone turn may dispatch at once, to bound what an unattended agent can spend per turn. The agent merges work that shares a slice or chain first, then takes the most valuable pieces up to this many and leaves the rest for a later turn. 1 makes it do the single most valuable piece itself, without spawning anything. Default 5. Ignored without --standalone — prompt turns are one piece of work by definition.', '5')
|
|
@@ -3494,6 +3584,9 @@ credentialFlags(program
|
|
|
3494
3584
|
// should fail on the spot, not once the loop is already up — and a cap passed where
|
|
3495
3585
|
// nothing will read it is worth saying out loud rather than ignoring silently.
|
|
3496
3586
|
const maxAgents = parseMaxAgents(opts.maxAgents);
|
|
3587
|
+
// --agent is --exec with the command filled in for the loop this run picks.
|
|
3588
|
+
const agentExec = resolveAgentPreset(opts, (opts.modeling || opts.standalone || opts.worker || opts.global) ? 'modeling' : 'build');
|
|
3589
|
+
if (agentExec) opts.exec = agentExec;
|
|
3497
3590
|
if (command.getOptionValueSource('maxAgents') === 'cli' && !opts.standalone) {
|
|
3498
3591
|
console.log('ℹ️ --max-agents only applies to --standalone turns; ignoring it here.');
|
|
3499
3592
|
}
|
|
@@ -3550,14 +3643,19 @@ credentialFlags(program
|
|
|
3550
3643
|
// no meaning for a build kit, which is scaffolded per project by definition.
|
|
3551
3644
|
if (opts.modeling || opts.standalone || opts.worker || opts.global) {
|
|
3552
3645
|
const picked = opts.modeling ? '--modeling' : opts.standalone ? '--standalone' : opts.worker ? '--worker' : '--global';
|
|
3553
|
-
// --bash
|
|
3554
|
-
//
|
|
3555
|
-
// queue, and the modeling loop has its own runner for
|
|
3556
|
-
|
|
3557
|
-
|
|
3646
|
+
// --bash stays build-kit only: it is the bash tasks.json loop, which the modeling loop has
|
|
3647
|
+
// no equivalent of. --local-ai and --exec are different — they name what does a turn, not a
|
|
3648
|
+
// queue, and the modeling loop has its own runner for each (lib/modeling-local-ai.js,
|
|
3649
|
+
// lib/modeling-exec.js).
|
|
3650
|
+
if (opts.bash) {
|
|
3651
|
+
console.error(`❌ ${picked} is mutually exclusive with --bash — that selects a build-kit runner, which the modeling loop has no use for.`);
|
|
3558
3652
|
process.exit(1);
|
|
3559
3653
|
}
|
|
3560
3654
|
const modelingLocalAi = resolveLocalAiTarget(opts);
|
|
3655
|
+
if (modelingLocalAi !== null && opts.exec) {
|
|
3656
|
+
console.error('❌ --local-ai and --exec are mutually exclusive — pick one runner.');
|
|
3657
|
+
process.exit(1);
|
|
3658
|
+
}
|
|
3561
3659
|
if (opts.local) {
|
|
3562
3660
|
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.`);
|
|
3563
3661
|
process.exit(1);
|
|
@@ -3596,10 +3694,10 @@ credentialFlags(program
|
|
|
3596
3694
|
// needed to drain, so a piped watcher sees the ping arrive after runModeling's own
|
|
3597
3695
|
// [modeling] log lines instead of before them.
|
|
3598
3696
|
const shown = relative(cwd, kitDir);
|
|
3599
|
-
const runnerLabel = modelingLocalAi ? 'local model' : 'warm Claude process';
|
|
3697
|
+
const runnerLabel = modelingLocalAi ? 'local model' : opts.exec ? 'one agent process per turn' : 'warm Claude process';
|
|
3600
3698
|
await new Promise((res) => process.stdout.write(`▶ Starting modeling loop (${runnerLabel}) for ${shown && !shown.startsWith('..') ? shown : kitDir}...\n\n`, res));
|
|
3601
3699
|
try {
|
|
3602
|
-
await runModeling(kitDir, projectDir, { verbose: !!opts.verbose, standalone: !!opts.standalone, exclusive: !!opts.exclusive, worker: !!opts.worker, overrides, maxAgents, identity, localAi: modelingLocalAi });
|
|
3700
|
+
await runModeling(kitDir, projectDir, { verbose: !!opts.verbose, standalone: !!opts.standalone, exclusive: !!opts.exclusive, worker: !!opts.worker, overrides, maxAgents, identity, localAi: modelingLocalAi, exec: opts.exec ?? null });
|
|
3603
3701
|
} catch (err) {
|
|
3604
3702
|
console.error('[modeling] Fatal:', err);
|
|
3605
3703
|
process.exit(1);
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// `run --modeling --exec "<command>"`: the modeling loop's baseline runner — any agent
|
|
2
|
+
// harness with a headless mode, one process per turn.
|
|
3
|
+
//
|
|
4
|
+
// The warm Claude runner and --local-ai own the whole session; this one owns nothing. The loop
|
|
5
|
+
// around it already does everything that needs no model — the heartbeat, the realtime channel,
|
|
6
|
+
// claiming prompts and chat messages — so all a turn needs is an agent that takes one prompt,
|
|
7
|
+
// does the board work over MCP and exits. The MCP registration is written for it (registerMcp).
|
|
8
|
+
// That is what `codex exec`, `gemini -p`, `opencode run` and whatever comes next already are,
|
|
9
|
+
// so one generic spawn covers them, the same bet shared/build-kit/ralph-exec.js makes for a
|
|
10
|
+
// build kit. The price is a fresh process per turn: seconds of startup, and a session that
|
|
11
|
+
// remembers nothing — which the chat can afford, since a chat turn reads its conversation from
|
|
12
|
+
// the platform anyway rather than from the session's memory.
|
|
13
|
+
//
|
|
14
|
+
// The prompt is appended to the command as one quoted argument and also written to the file
|
|
15
|
+
// named by RALPH_PROMPT_FILE, for a command that would rather read it — as ralph-exec.js does.
|
|
16
|
+
// What the command prints on stdout is the turn's result: the standalone lane reads its
|
|
17
|
+
// NOOP/DONE answer from it, and a chat turn that forgot post_chat_message has it posted as the
|
|
18
|
+
// reply. So run the harness in its plain-text mode, not a JSON one.
|
|
19
|
+
|
|
20
|
+
import { spawn } from 'child_process';
|
|
21
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'fs';
|
|
22
|
+
import { tmpdir } from 'os';
|
|
23
|
+
import { dirname, join } from 'path';
|
|
24
|
+
import { turnTimeoutMs, superviseTurn } from '../shared/build-kit/lib/turn.js';
|
|
25
|
+
|
|
26
|
+
// POSIX single-quote escaping: close, insert an escaped quote, reopen. A turn is multi-line
|
|
27
|
+
// text with backticks and $ in it, so it cannot go in unquoted.
|
|
28
|
+
function shellQuote(s) {
|
|
29
|
+
return `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Adds or refreshes one key in a project JSON config, leaving everything else in it alone. A file
|
|
33
|
+
// that isn't plain JSON (a JSONC one with comments) is not rewritten — returns false instead, and
|
|
34
|
+
// the caller says what to add by hand.
|
|
35
|
+
function mergeJsonConfig(path, apply) {
|
|
36
|
+
let config = {};
|
|
37
|
+
if (existsSync(path)) {
|
|
38
|
+
try { config = JSON.parse(readFileSync(path, 'utf8')); } catch { return false; }
|
|
39
|
+
}
|
|
40
|
+
apply(config);
|
|
41
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
42
|
+
writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`);
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// How each known harness learns about the eventmodelers MCP server — the --exec counterpart of
|
|
47
|
+
// ensureMcpRegistered's `.mcp.json` for Claude, and like it rewritten every run, always into the
|
|
48
|
+
// harness's *project* config in the agent's working dir (never a user-wide one), and always with
|
|
49
|
+
// the token as an env reference the harness resolves from the env this runner hands it, never its
|
|
50
|
+
// value. Each entry returns the command to run, which is where a harness without a usable project
|
|
51
|
+
// config (Codex only loads one for a trusted project) takes the server as flags instead. Adding a
|
|
52
|
+
// harness is one entry here.
|
|
53
|
+
const MCP_REGISTRATIONS = {
|
|
54
|
+
opencode: (command, projectDir, url) => {
|
|
55
|
+
const entry = {
|
|
56
|
+
type: 'remote',
|
|
57
|
+
url,
|
|
58
|
+
headers: { 'x-token': '{env:EVENTMODELERS_TOKEN}', 'x-agent-id': '{env:EVENTMODELERS_AGENT_ID}' },
|
|
59
|
+
};
|
|
60
|
+
const ok = mergeJsonConfig(join(projectDir, 'opencode.json'), (config) => {
|
|
61
|
+
// OpenCode 2 nests servers under mcp.servers and still reads the older flat mcp.<name>;
|
|
62
|
+
// follow whichever form the file already uses.
|
|
63
|
+
config.mcp = config.mcp || {};
|
|
64
|
+
if (config.mcp.servers) config.mcp.servers.eventmodelers = entry;
|
|
65
|
+
else config.mcp.eventmodelers = entry;
|
|
66
|
+
});
|
|
67
|
+
return { command, file: ok ? 'opencode.json' : null, manual: ok ? null : `add mcp.eventmodelers = ${JSON.stringify(entry)} to opencode.json` };
|
|
68
|
+
},
|
|
69
|
+
gemini: (command, projectDir, url) => {
|
|
70
|
+
const entry = { httpUrl: url, headers: { 'x-token': '$EVENTMODELERS_TOKEN', 'x-agent-id': '$EVENTMODELERS_AGENT_ID' } };
|
|
71
|
+
const ok = mergeJsonConfig(join(projectDir, '.gemini', 'settings.json'), (config) => {
|
|
72
|
+
config.mcpServers = config.mcpServers || {};
|
|
73
|
+
config.mcpServers.eventmodelers = entry;
|
|
74
|
+
});
|
|
75
|
+
return { command, file: ok ? '.gemini/settings.json' : null, manual: ok ? null : `add mcpServers.eventmodelers = ${JSON.stringify(entry)} to .gemini/settings.json` };
|
|
76
|
+
},
|
|
77
|
+
codex: (command, _projectDir, url) => ({
|
|
78
|
+
// -c values are TOML; env_http_headers names the env var each header is read from.
|
|
79
|
+
command: `${command} -c ${shellQuote(`mcp_servers.eventmodelers.url="${url}"`)} -c ${shellQuote('mcp_servers.eventmodelers.env_http_headers={x-token="EVENTMODELERS_TOKEN",x-agent-id="EVENTMODELERS_AGENT_ID"}')}`,
|
|
80
|
+
file: null,
|
|
81
|
+
manual: null,
|
|
82
|
+
}),
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// Which known harness a command runs — the first word naming one, so `npx @openai/codex exec`
|
|
86
|
+
// and `/opt/bin/opencode run` are recognised as well as the bare names.
|
|
87
|
+
export function harnessOf(command) {
|
|
88
|
+
for (const word of String(command).trim().split(/\s+/).slice(0, 3)) {
|
|
89
|
+
const name = word.split('/').pop().replace(/@.*$/, '');
|
|
90
|
+
if (MCP_REGISTRATIONS[name]) return name;
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Registers the MCP server for the command's harness. Returns the command to run with it, and
|
|
96
|
+
// whether the server is now registered — false for an unknown command or a config it could not
|
|
97
|
+
// update, where the turn still has to connect the way the kit says to.
|
|
98
|
+
export function registerMcp(command, projectDir, baseUrl, log) {
|
|
99
|
+
const url = `${baseUrl}/mcp`;
|
|
100
|
+
const harness = harnessOf(command);
|
|
101
|
+
if (!harness) {
|
|
102
|
+
log(`MCP: unknown agent command — register the eventmodelers MCP server with it yourself: ${url}, header x-token from $EVENTMODELERS_TOKEN (and x-agent-id from $EVENTMODELERS_AGENT_ID)`);
|
|
103
|
+
return { command, registered: false };
|
|
104
|
+
}
|
|
105
|
+
const reg = MCP_REGISTRATIONS[harness](command, projectDir, url);
|
|
106
|
+
if (reg.manual) log(`MCP: could not update the ${harness} config (not plain JSON) — ${reg.manual}`);
|
|
107
|
+
else log(`MCP: eventmodelers registered for ${harness}${reg.file ? ` in ${reg.file}` : ' via -c flags'}`);
|
|
108
|
+
return { command: reg.command, registered: !reg.manual };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function createModelingExecRunner({ cfg, command: rawCommand, projectDir, log }) {
|
|
112
|
+
if (!rawCommand) {
|
|
113
|
+
throw new Error('no agent command — pass one, e.g. --exec "codex exec --full-auto", or persist it as localAi.exec in .eventmodelers/config.json');
|
|
114
|
+
}
|
|
115
|
+
const { command, registered: mcpRegistered } = registerMcp(rawCommand, projectDir, cfg.baseUrl, log);
|
|
116
|
+
const timeoutMs = turnTimeoutMs(cfg);
|
|
117
|
+
const promptDir = mkdtempSync(join(tmpdir(), 'modeling-exec-'));
|
|
118
|
+
const env = {
|
|
119
|
+
...process.env,
|
|
120
|
+
// What the skills' curl fallback and an MCP registration reading ${EVENTMODELERS_TOKEN}
|
|
121
|
+
// authenticate with, and what attributes the agent's board writes to this agent.
|
|
122
|
+
EVENTMODELERS_TOKEN: cfg.token,
|
|
123
|
+
...(cfg.agentId ? { EVENTMODELERS_AGENT_ID: cfg.agentId } : {}),
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
function runTurn(text) {
|
|
127
|
+
return new Promise((resolveTurn, rejectTurn) => {
|
|
128
|
+
const promptFile = join(promptDir, 'prompt.md');
|
|
129
|
+
writeFileSync(promptFile, text);
|
|
130
|
+
const startedAt = Date.now();
|
|
131
|
+
// stdin ignored: some harnesses append a piped stdin to the prompt, and nobody is there to
|
|
132
|
+
// type into it. stdout is echoed as it comes — the harness owns its output format, there is
|
|
133
|
+
// no cross-harness stream to condense — and kept as the turn's result.
|
|
134
|
+
const proc = spawn(`${command} ${shellQuote(text)}`, {
|
|
135
|
+
cwd: projectDir,
|
|
136
|
+
stdio: ['ignore', 'pipe', 'inherit'],
|
|
137
|
+
shell: true,
|
|
138
|
+
env: { ...env, RALPH_PROMPT_FILE: promptFile },
|
|
139
|
+
});
|
|
140
|
+
const turn = superviseTurn(proc, { timeoutMs, label: 'exec turn', viaShell: true, log });
|
|
141
|
+
let out = '';
|
|
142
|
+
proc.stdout.on('data', (chunk) => {
|
|
143
|
+
process.stdout.write(chunk);
|
|
144
|
+
out += chunk.toString();
|
|
145
|
+
});
|
|
146
|
+
proc.on('close', (code) => {
|
|
147
|
+
const timeout = turn.timeoutError();
|
|
148
|
+
if (timeout) return rejectTurn(timeout);
|
|
149
|
+
if (code !== 0) return rejectTurn(new Error(`exec command exited ${code}`));
|
|
150
|
+
log(`done (${Date.now() - startedAt}ms)`);
|
|
151
|
+
resolveTurn(out.trim());
|
|
152
|
+
});
|
|
153
|
+
proc.on('error', rejectTurn);
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
runTurn,
|
|
159
|
+
// Nothing to warm: every turn is its own process.
|
|
160
|
+
warmUp: async () => {},
|
|
161
|
+
describe: () => rawCommand,
|
|
162
|
+
mcpRegistered,
|
|
163
|
+
};
|
|
164
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eventmodelers/cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.97",
|
|
4
4
|
"description": "Eventmodelers CLI — real-time Claude agent + skills for Claude Code, for any stack (Node, Supabase, Axon, OpenCQRS, UmaDB, Kurrent, or modeling-only)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -121,7 +121,17 @@ llama.cpp `-c 32768`); an overflow there surfaces as an HTTP 400.
|
|
|
121
121
|
|
|
122
122
|
Agentic harnesses that bring their own tool loop — Codex CLI, OpenCode, Gemini CLI —
|
|
123
123
|
are not `--local-ai` targets: `--local-ai` *supplies* the agent loop, while a harness
|
|
124
|
-
already is one and only wants a prompt. They go through `ralph-exec.js` instead
|
|
124
|
+
already is one and only wants a prompt. They go through `ralph-exec.js` instead. The
|
|
125
|
+
known ones have a shortcut that fills in the command, JSON flag for agent tracing included
|
|
126
|
+
(bare `--agent` lists them):
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
npx @eventmodelers/cli run --agent opencode # = --exec "opencode run --auto --format json"
|
|
130
|
+
npx @eventmodelers/cli run --agent codex # = --exec "codex exec --json --full-auto"
|
|
131
|
+
npx @eventmodelers/cli run --agent gemini # = --exec "gemini --yolo --output-format stream-json -p"
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
`--exec` is the manual fallback: any other harness, or one of these with other flags:
|
|
125
135
|
|
|
126
136
|
```bash
|
|
127
137
|
npx @eventmodelers/cli run --exec "codex exec --full-auto"
|