@eventmodelers/cli 1.0.88 → 1.0.90

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
@@ -55,6 +55,8 @@ npx @eventmodelers/cli run
55
55
 
56
56
  `run` doesn't re-configure anything. It finds whatever kit dir `init` already created in this project and starts its agent loop (`ralph-claude.js`) against your existing `.eventmodelers/config.json`.
57
57
 
58
+ Each agent turn is capped at 60 minutes by default so a hung agent can't stall the loop: it gets SIGTERM, then SIGKILL 10s later, and a slice that keeps timing out is Blocked by the stuck-slice guard. Change the cap with `"turnTimeoutMinutes"` in `.eventmodelers/config.json` or `RALPH_TURN_TIMEOUT_MIN` for one run; `0` turns it off.
59
+
58
60
  **Pulling the latest board state without starting an agent** — e.g. to inspect a context's slices, or in a script:
59
61
 
60
62
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eventmodelers/cli",
3
- "version": "1.0.88",
3
+ "version": "1.0.90",
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": {
@@ -0,0 +1,12 @@
1
+ // Exponential backoff with jitter: baseMs, 2×, 4×, … capped at capMs. Half the delay is
2
+ // fixed, half random, so agents that failed together (a platform restart takes every agent
3
+ // on it down at once) don't all come back in the same second.
4
+ //
5
+ // attempt is 1-based — the delay before the first retry is between baseMs/2 and baseMs.
6
+
7
+ export function backoffMs(attempt, { baseMs, capMs }) {
8
+ const ceiling = Math.min(capMs, baseMs * 2 ** Math.max(0, attempt - 1));
9
+ return Math.round(ceiling / 2 + Math.random() * (ceiling / 2));
10
+ }
11
+
12
+ export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -11,6 +11,7 @@ import { join, dirname } from 'path';
11
11
  import { homedir } from 'os';
12
12
  import { randomUUID } from 'crypto';
13
13
  import { createRealtimeAdapter } from './adapters/realtime-adapter.js';
14
+ import { backoffMs, sleep } from './backoff.js';
14
15
 
15
16
  // ── HTTP helpers ──────────────────────────────────────────────────────────────
16
17
 
@@ -27,21 +28,19 @@ async function fetchJSON(url, options) {
27
28
  return res.json();
28
29
  }
29
30
 
30
- async function retryOn401(label, fn, maxRetries = 3) {
31
- for (let attempt = 1; attempt <= maxRetries; attempt++) {
32
- try {
33
- return await fn();
34
- } catch (err) {
35
- if (err instanceof HttpError && err.status === 401) {
36
- if (attempt < maxRetries) {
37
- console.warn(`[agent] ${label} — 401, retrying (${attempt}/${maxRetries})...`);
38
- continue;
39
- }
40
- console.error(`[agent] ${label} — 401 after ${maxRetries} retries, shutting down`);
41
- process.exit(1);
42
- }
43
- throw err;
31
+ // A 401 from the platform always means the token itself is missing, invalid or revoked (see
32
+ // requireApiToken on the platform) — retrying the same token can't fix that, and neither can
33
+ // waiting, so the first one ends the process with a message saying what to fix.
34
+ async function exitOn401(label, fn) {
35
+ try {
36
+ return await fn();
37
+ } catch (err) {
38
+ if (err instanceof HttpError && err.status === 401) {
39
+ console.error(`[agent] ${label} — 401, the token was rejected: ${err.message}`);
40
+ console.error('[agent] Check the token in .eventmodelers/config.json (or EVENTMODELERS_TOKEN) — shutting down');
41
+ process.exit(1);
44
42
  }
43
+ throw err;
45
44
  }
46
45
  }
47
46
 
@@ -272,9 +271,18 @@ async function writeTask(payload, kitDir) {
272
271
 
273
272
  async function handleSliceChanged(payload, cfg, kitDir, queueAllStatuses) {
274
273
  console.log(`[agent] slice:changed — slice="${payload.sliceTitle}" status="${payload.sliceStatus}"`);
275
- await retryOn401('fetchAndPersistSlices', () => fetchAndPersistSlices(cfg, kitDir)).catch((err) =>
274
+ await exitOn401('fetchAndPersistSlices', () => fetchAndPersistSlices(cfg, kitDir)).catch((err) =>
276
275
  console.error('[agent] Slice persist error:', err),
277
276
  );
277
+ // This agent's own status writes (claim → InProgress, → Done, → Blocked) come straight back
278
+ // as slice:changed. Queuing them only buys a turn in which the agent reads its own echo and
279
+ // skips it. Attribution decides, never a guess: only an agentId equal to ours is dropped —
280
+ // a person's edit or another agent's (different id), or an event with no attribution at all,
281
+ // is still queued, so a missing field degrades to the old behaviour rather than losing work.
282
+ if (payload?.agentId && cfg.agentId && payload.agentId === cfg.agentId) {
283
+ console.log(`[agent] Own write — not queued (slice="${payload.sliceTitle}" status="${payload.sliceStatus}")`);
284
+ return;
285
+ }
278
286
  // Planned slices are handled by onPlannedSlice directly — no task needed.
279
287
  // queueAllStatuses opts out of that split entirely (e.g. bridge has no
280
288
  // onPlannedSlice consumer, so a lingering Planned slice would otherwise
@@ -285,9 +293,9 @@ async function handleSliceChanged(payload, cfg, kitDir, queueAllStatuses) {
285
293
  }
286
294
 
287
295
  async function startRealtimeAgent(cfg, kitDir, { agentType = 'BUILD', queueAllStatuses = false } = {}) {
288
- let realtimeToken = await retryOn401('getRealtimeToken', () => getRealtimeToken(cfg));
296
+ let realtimeToken = await exitOn401('getRealtimeToken', () => getRealtimeToken(cfg));
289
297
 
290
- await retryOn401('fetchAndPersistSlices', () => fetchAndPersistSlices(cfg, kitDir)).catch((err) =>
298
+ await exitOn401('fetchAndPersistSlices', () => fetchAndPersistSlices(cfg, kitDir)).catch((err) =>
291
299
  console.error('[agent] Initial slice fetch error:', err),
292
300
  );
293
301
 
@@ -305,7 +313,7 @@ async function startRealtimeAgent(cfg, kitDir, { agentType = 'BUILD', queueAllSt
305
313
  console.log(`[agent] ${ts()} Refreshing realtime token (reason: ${reason})...`);
306
314
  refreshing = (async () => {
307
315
  try {
308
- realtimeToken = await retryOn401('getRealtimeToken (refresh)', () => getRealtimeToken(cfg));
316
+ realtimeToken = await exitOn401('getRealtimeToken (refresh)', () => getRealtimeToken(cfg));
309
317
  await realtime.setAuth(realtimeToken);
310
318
  console.log(`[agent] ${ts()} Token refreshed (reason: ${reason}, took ${Date.now() - startedAt}ms)`);
311
319
  } catch (err) {
@@ -321,11 +329,33 @@ async function startRealtimeAgent(cfg, kitDir, { agentType = 'BUILD', queueAllSt
321
329
 
322
330
  // A subscribe that errors on a stale token needs a fresh token AND a new join
323
331
  // attempt — setAuth alone doesn't re-join a channel that already errored out.
324
- // Capped so a non-expiry auth failure (e.g. genuinely revoked access) can't turn
325
- // into a tight resubscribe loop hammering the platform forever.
332
+ // Retried with exponential backoff (2s → 5min) rather than a fixed delay and a cap:
333
+ // the cap used to leave the channel dead for good — the scheduled refresh only calls
334
+ // setAuth, it never re-joins — while the backoff alone keeps a genuinely revoked
335
+ // access from hammering the platform. A revoked token ends the process anyway, via
336
+ // exitOn401 inside refreshToken.
326
337
  let channelErrorStreak = 0;
338
+ let resubscribeTimer = null;
339
+ const onChannelFailure = (reason) => {
340
+ // One failure can be reported more than once (a channel erroring while its retry is
341
+ // already scheduled); a second resubscribe on top would join the channel twice.
342
+ if (resubscribeTimer) return;
343
+ channelErrorStreak += 1;
344
+ const delay = backoffMs(channelErrorStreak, { baseMs: 2_000, capMs: 5 * 60_000 });
345
+ console.warn(`[agent] ${ts()} Channel "${channelName}": ${reason} (attempt ${channelErrorStreak}) — resubscribing in ${Math.round(delay / 1000)}s`);
346
+ resubscribeTimer = setTimeout(async () => {
347
+ try {
348
+ await refreshToken(`channel ${reason}`);
349
+ } catch {
350
+ // Logged by refreshToken. Rejoining on the old token is still worth a try —
351
+ // if it fails too, that's just the next failure, with a longer delay.
352
+ }
353
+ resubscribeTimer = null;
354
+ subscribeChannel();
355
+ }, delay);
356
+ };
327
357
  const subscribeChannel = () => {
328
- realtime.subscribe(
358
+ Promise.resolve(realtime.subscribe(
329
359
  channelName,
330
360
  {
331
361
  // A kill names exactly one agent: {type: 'kill', id: '<agentId>', instruction: 'exit'}.
@@ -340,32 +370,22 @@ async function startRealtimeAgent(cfg, kitDir, { agentType = 'BUILD', queueAllSt
340
370
  },
341
371
  'slice:changed': (payload) => handleSliceChanged(payload, cfg, kitDir, queueAllStatuses),
342
372
  },
343
- async (status) => {
344
- if (status !== 'CHANNEL_ERROR' && status !== 'TIMED_OUT') {
345
- if (channelErrorStreak > 0) {
346
- console.log(`[agent] ${ts()} Channel "${channelName}": ${status} — recovered after ${channelErrorStreak} failed attempt(s)`);
347
- } else {
348
- console.log(`[agent] ${ts()} Channel "${channelName}": ${status}`);
349
- }
350
- channelErrorStreak = 0;
351
- return;
352
- }
353
- channelErrorStreak += 1;
354
- console.warn(`[agent] ${ts()} Channel "${channelName}": ${status} (attempt ${channelErrorStreak}/5)`);
355
- if (channelErrorStreak > 5) {
356
- console.error(`[agent] ${ts()} Channel "${channelName}" failed ${channelErrorStreak} times in a row — giving up until the next scheduled token refresh (every 10min)`);
373
+ (status) => {
374
+ if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') return onChannelFailure(status);
375
+ if (status !== 'SUBSCRIBED' || channelErrorStreak === 0) {
376
+ console.log(`[agent] ${ts()} Channel "${channelName}": ${status}`);
357
377
  return;
358
378
  }
359
- try {
360
- await refreshToken(`channel ${status}`);
361
- await new Promise((r) => setTimeout(r, 2_000));
362
- console.log(`[agent] ${ts()} Resubscribing to channel "${channelName}" (attempt ${channelErrorStreak}/5)...`);
363
- subscribeChannel();
364
- } catch (err) {
365
- console.error(`[agent] ${ts()} Token refresh after channel error failed, will not resubscribe this round:`, err);
366
- }
379
+ console.log(`[agent] ${ts()} Channel "${channelName}": ${status} — recovered after ${channelErrorStreak} failed attempt(s)`);
380
+ channelErrorStreak = 0;
381
+ // Broadcasts sent while the channel was down are gone. Re-read the slices once so a
382
+ // slice set to Planned during the gap still gets built — .slices/ is what ralphLoop
383
+ // picks Planned work from.
384
+ exitOn401('fetchAndPersistSlices (after reconnect)', () => fetchAndPersistSlices(cfg, kitDir)).catch((err) =>
385
+ console.error(`[agent] ${ts()} Slice re-fetch after reconnect failed:`, err),
386
+ );
367
387
  },
368
- );
388
+ )).catch((err) => onChannelFailure(`subscribe failed: ${err.message}`));
369
389
  };
370
390
  subscribeChannel();
371
391
 
@@ -531,15 +551,26 @@ async function blockStuckSlice(kitDir, cfg, credentialed, planned, attempts) {
531
551
  console.error(`[ralph] ${reason} Marked "${planned.title}" (id=${planned.id}) as Blocked — moving on.`);
532
552
  }
533
553
 
554
+ // Returns true once fn succeeds; false for a turn that timed out (see lib/turn.js). A timeout
555
+ // is not retried in place: re-running the same hung turn every 60s would spin forever without
556
+ // the stuck-slice guard ever seeing it, so it goes back to ralphLoop, which recounts the slice.
557
+ // Any other failure is retried in place with exponential backoff (30s → 10min): a harness
558
+ // that fails fast (API outage, rate limit, broken install) used to be relaunched every 60s
559
+ // for as long as it stayed broken.
534
560
  async function runWithRetry(label, fn) {
535
- while (true) {
561
+ for (let attempt = 1; ; attempt++) {
536
562
  try {
537
563
  console.log(`[ralph] ${label}`);
538
564
  await fn();
539
- return;
565
+ return true;
540
566
  } catch (err) {
541
- console.error(`[ralph] Error — retrying in 60s:`, err.message);
542
- await new Promise((r) => setTimeout(r, 60_000));
567
+ if (err?.timedOut) {
568
+ console.error(`[ralph] ${err.message} — back to the loop`);
569
+ return false;
570
+ }
571
+ const delay = backoffMs(attempt, { baseMs: 30_000, capMs: 10 * 60_000 });
572
+ console.error(`[ralph] Error (attempt ${attempt}) — retrying in ${Math.round(delay / 1000)}s:`, err.message);
573
+ await sleep(delay);
543
574
  }
544
575
  }
545
576
  }
@@ -580,7 +611,7 @@ async function ralphLoop(kitDir, cfg, onTask, onPlannedSlice, localOnly = false)
580
611
  }
581
612
 
582
613
  const prompt = readFileSync(backendPromptFile, 'utf-8');
583
- await runWithRetry(`onPlannedSlice: building slice "${planned.title}"...`, () => onPlannedSlice(prompt, {
614
+ const built = await runWithRetry(`onPlannedSlice: building slice "${planned.title}"...`, () => onPlannedSlice(prompt, {
584
615
  sliceId: planned.id,
585
616
  sliceTitle: planned.title,
586
617
  context: planned.ctx,
@@ -588,7 +619,7 @@ async function ralphLoop(kitDir, cfg, onTask, onPlannedSlice, localOnly = false)
588
619
  boardId: cfg.boardId ?? null,
589
620
  attempt: stuckSlice.count,
590
621
  }));
591
- console.log(`[ralph] Slice build complete — waiting for next slice`);
622
+ if (built) console.log(`[ralph] Slice build complete — waiting for next slice`);
592
623
  if (credentialed) await fetchAndPersistSlices(cfg, kitDir).catch(() => {});
593
624
  didWork = true;
594
625
  }
@@ -609,7 +640,7 @@ async function ralphLoop(kitDir, cfg, onTask, onPlannedSlice, localOnly = false)
609
640
 
610
641
  // ── Public API ────────────────────────────────────────────────────────────────
611
642
 
612
- export { loadLocalConfig, fetchPlatformConfig, retryOn401, startRealtimeAgent };
643
+ export { loadLocalConfig, fetchPlatformConfig, exitOn401, startRealtimeAgent };
613
644
 
614
645
  // Who this process is. RALPH_AGENT_ID/RALPH_AGENT_NAME are `eventmodelers run --id/--name`,
615
646
  // passed down as env (see cli.js's run dispatcher): a per-run identity override so a second
@@ -643,7 +674,7 @@ export async function startRalph({ kitDir, projectDir, onTask, onPlannedSlice, a
643
674
  return;
644
675
  }
645
676
 
646
- const cfg = await retryOn401('fetchPlatformConfig', () => fetchPlatformConfig(local));
677
+ const cfg = await exitOn401('fetchPlatformConfig', () => fetchPlatformConfig(local));
647
678
  console.log(` org=${cfg.organizationId}, board=${cfg.boardId}, base=${cfg.baseUrl}\n`);
648
679
 
649
680
  await Promise.all([
@@ -0,0 +1,57 @@
1
+ // Wall-clock cap on one agent turn. Without it a hung harness (a stuck tool call, a
2
+ // network stall, an interactive prompt nobody answers) blocks the loop forever — no
3
+ // further tasks, no further slices, and the stuck-slice guard never gets a look in.
4
+ //
5
+ // RALPH_TURN_TIMEOUT_MIN (env) or turnTimeoutMinutes (.eventmodelers/config.json);
6
+ // default 60, 0 turns the cap off.
7
+
8
+ import { execFile } from 'child_process';
9
+
10
+ const KILL_GRACE_MS = 10_000;
11
+
12
+ export function turnTimeoutMs(cfg) {
13
+ const raw = process.env.RALPH_TURN_TIMEOUT_MIN ?? cfg?.turnTimeoutMinutes ?? 60;
14
+ const minutes = Number(raw);
15
+ return Number.isFinite(minutes) && minutes > 0 ? minutes * 60_000 : 0;
16
+ }
17
+
18
+ // Thrown (rejected) by a runner whose turn hit the cap. ralph.js's runWithRetry lets it
19
+ // through to the outer loop instead of re-running the same turn in place, so a slice
20
+ // that times out again is counted — and eventually Blocked — by the stuck-slice guard.
21
+ export class TurnTimeoutError extends Error {
22
+ constructor(label, timeoutMs) {
23
+ super(`${label} timed out after ${Math.round(timeoutMs / 60_000)}min`);
24
+ this.timedOut = true;
25
+ }
26
+ }
27
+
28
+ // SIGTERM, then SIGKILL after a grace period. `shell: true` means proc is only the /bin/sh
29
+ // wrapping the real command, so its children are signalled first — killing just the shell
30
+ // would orphan the harness and leave it running. (A separate process group would be
31
+ // cleaner, but a background group reading the terminal gets stopped by SIGTTIN, and
32
+ // Ctrl-C would no longer reach it.)
33
+ // The shell is only signalled once pkill has returned: killed any earlier, its children are
34
+ // re-parented to init before pkill -P gets to look for them, and survive.
35
+ function signal(proc, sig, viaShell) {
36
+ const killSelf = () => { try { proc.kill(`SIG${sig}`); } catch { /* already gone */ } };
37
+ if (viaShell && proc.pid) execFile('pkill', [`-${sig}`, '-P', String(proc.pid)], killSelf);
38
+ else killSelf();
39
+ }
40
+
41
+ export function superviseTurn(proc, { timeoutMs, label, log, viaShell = false }) {
42
+ let timedOut = false;
43
+ let killTimer;
44
+ const timer = timeoutMs
45
+ ? setTimeout(() => {
46
+ timedOut = true;
47
+ log(`${label} exceeded ${Math.round(timeoutMs / 60_000)}min — stopping it`);
48
+ signal(proc, 'TERM', viaShell);
49
+ killTimer = setTimeout(() => signal(proc, 'KILL', viaShell), KILL_GRACE_MS);
50
+ }, timeoutMs)
51
+ : null;
52
+ proc.on('close', () => {
53
+ clearTimeout(timer);
54
+ clearTimeout(killTimer);
55
+ });
56
+ return { timedOut: () => timedOut };
57
+ }
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { startRalph, loadLocalConfig, resolveAgentIdentity } from './lib/ralph.js';
6
6
  import { createSliceTracer, usageFromClaudeResult } from './lib/tracing.js';
7
+ import { turnTimeoutMs, superviseTurn, TurnTimeoutError } from './lib/turn.js';
7
8
  import { spawn } from 'child_process';
8
9
  import { dirname, join, resolve } from 'path';
9
10
  import { fileURLToPath } from 'url';
@@ -33,9 +34,17 @@ const tracer = createSliceTracer({
33
34
  // --local must mean zero board contact — never hand Claude live board
34
35
  // credentials via the inline header, even if config.json has them, or it'll
35
36
  // treat them as already-connected and skip straight to board sync.
37
+ // The token itself stays out of the prompt: `-p` is argv, visible to every local
38
+ // user in `ps`, and the prompt lands in session transcripts. The header names the
39
+ // env var instead (set in claudeEnv below) — connect treats it as the inline token,
40
+ // and a curl header written as "x-token: $EVENTMODELERS_TOKEN" expands in the shell.
36
41
  const inlineHeader = !localOnly && cfg.boardId
37
- ? `board=${cfg.boardId} token=${cfg.token} org=${cfg.organizationId} baseUrl=${cfg.baseUrl}\n\n`
42
+ ? `board=${cfg.boardId} token=$EVENTMODELERS_TOKEN org=${cfg.organizationId} baseUrl=${cfg.baseUrl}\n\n`
38
43
  : '';
44
+ const timeoutMs = turnTimeoutMs(cfg);
45
+ // The model can still surface the token (e.g. a Bash command it expanded by hand), and
46
+ // --verbose logs tool input and assistant text as is — mask it before it hits the log.
47
+ const redact = (s) => (cfg.token ? String(s).split(cfg.token).join('***') : s);
39
48
 
40
49
  // --verbose here (set via `eventmodelers run --verbose`, passed down as RALPH_VERBOSE)
41
50
  // logs full tool input and assistant reasoning text; the default (condensed) mode logs
@@ -86,6 +95,7 @@ function runClaude(prompt, slice = null) {
86
95
  stdio: ['inherit', 'pipe', 'inherit'],
87
96
  env: claudeEnv,
88
97
  });
98
+ const turn = superviseTurn(proc, { timeoutMs, label: 'Claude turn', log: (line) => console.error(`[ralph] ${line}`) });
89
99
 
90
100
  let buffer = '';
91
101
  proc.stdout.on('data', (chunk) => {
@@ -99,9 +109,9 @@ function runClaude(prompt, slice = null) {
99
109
 
100
110
  if (msg.type === 'assistant') {
101
111
  for (const block of msg.message?.content ?? []) {
102
- if (block.type === 'text' && block.text && verbose) console.log(block.text);
112
+ if (block.type === 'text' && block.text && verbose) console.log(redact(block.text));
103
113
  if (block.type === 'tool_use') {
104
- if (verbose) console.log(`→ ${describeToolUse(block)}`);
114
+ if (verbose) console.log(`→ ${redact(describeToolUse(block))}`);
105
115
  else if (block.name === 'Skill') console.log(`→ Skill: ${block.input?.skill ?? ''}`);
106
116
  else console.log(`→ ${block.name}`);
107
117
  }
@@ -113,7 +123,11 @@ function runClaude(prompt, slice = null) {
113
123
  }
114
124
  });
115
125
 
116
- proc.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`Claude exited ${code}`))));
126
+ proc.on('close', (code) => {
127
+ if (turn.timedOut()) reject(new TurnTimeoutError('Claude turn', timeoutMs));
128
+ else if (code === 0) resolve();
129
+ else reject(new Error(`Claude exited ${code}`));
130
+ });
117
131
  proc.on('error', reject);
118
132
  });
119
133
  }
@@ -19,6 +19,7 @@
19
19
 
20
20
  import { startRalph, loadLocalConfig, resolveAgentIdentity } from './lib/ralph.js';
21
21
  import { createSliceTracer, usageFromHarnessEvent, sumUsage } from './lib/tracing.js';
22
+ import { turnTimeoutMs, superviseTurn, TurnTimeoutError } from './lib/turn.js';
22
23
  import { spawn } from 'child_process';
23
24
  import { writeFileSync, mkdtempSync } from 'fs';
24
25
  import { tmpdir } from 'os';
@@ -42,10 +43,12 @@ if (!execCmd) {
42
43
  }
43
44
 
44
45
  // Same rule as ralph-claude.js: --local must mean zero board contact, so credentials
45
- // never reach the child even when config.json has them.
46
+ // never reach the child even when config.json has them. Also as there: the token is
47
+ // passed by env var name, never by value — the prompt goes on the command line.
46
48
  const inlineHeader = !localOnly && cfg.boardId
47
- ? `board=${cfg.boardId} token=${cfg.token} org=${cfg.organizationId} baseUrl=${cfg.baseUrl}\n\n`
49
+ ? `board=${cfg.boardId} token=$EVENTMODELERS_TOKEN org=${cfg.organizationId} baseUrl=${cfg.baseUrl}\n\n`
48
50
  : '';
51
+ const timeoutMs = turnTimeoutMs(cfg);
49
52
 
50
53
  const childEnv = {
51
54
  ...process.env,
@@ -122,8 +125,10 @@ function runExec(prompt, slice = null) {
122
125
  shell: true,
123
126
  env: { ...childEnv, RALPH_PROMPT_FILE: promptFile },
124
127
  });
128
+ const turn = superviseTurn(proc, { timeoutMs, label: 'exec turn', viaShell: true, log: (line) => console.error(`[ralph-exec] ${line}`) });
125
129
  const usage = traced ? collectUsage(proc.stdout) : null;
126
130
  proc.on('close', (code) => {
131
+ if (turn.timedOut()) return reject(new TurnTimeoutError('exec turn', timeoutMs));
127
132
  if (traced) {
128
133
  const u = usage();
129
134
  if (u) tracer.record({ ...slice, status: code === 0 ? 'ok' : 'error' }, { ...u, durationMs: u.durationMs ?? Date.now() - startedAt });
@@ -4,14 +4,14 @@
4
4
  // only need this if you want to run the agent independently (e.g. separate terminal).
5
5
  // Usage: node realtime-agent.js [kit_dir]
6
6
 
7
- import { loadLocalConfig, fetchPlatformConfig, retryOn401, startRealtimeAgent } from './lib/ralph.js';
7
+ import { loadLocalConfig, fetchPlatformConfig, exitOn401, startRealtimeAgent } from './lib/ralph.js';
8
8
  import { dirname, resolve } from 'path';
9
9
  import { fileURLToPath } from 'url';
10
10
 
11
11
  const kitDir = process.argv[2] ? resolve(process.argv[2]) : dirname(fileURLToPath(import.meta.url));
12
12
 
13
13
  const local = loadLocalConfig(kitDir);
14
- const cfg = await retryOn401('fetchPlatformConfig', () => fetchPlatformConfig(local));
14
+ const cfg = await exitOn401('fetchPlatformConfig', () => fetchPlatformConfig(local));
15
15
 
16
16
  console.log(`[agent] Starting — org=${cfg.organizationId}${cfg.boardId ? `, board=${cfg.boardId}` : ''}, base=${cfg.baseUrl}`);
17
17
 
@@ -61,6 +61,8 @@ If an inline `board=<uuid>` is found, use it as `BOARD_ID` — **it takes priori
61
61
 
62
62
  (`agent=<uuid>` is not one of the four required values — it is optional, and its absence never makes this skill ask anything.)
63
63
 
64
+ **`token=$EVENTMODELERS_TOKEN` is an inline token too.** The build loop passes the env var's *name*, not its value, so the secret never sits in the prompt. Treat it exactly like a literal inline token: `TOKEN` is `$EVENTMODELERS_TOKEN` (it counts toward "all four inline" below). Keep using the reference, never its value — write curl headers as `-H "x-token: $EVENTMODELERS_TOKEN"` (double quotes, so the shell expands it), don't `echo` or print it, and hand it to a subagent as `token=$EVENTMODELERS_TOKEN` (it inherits the same environment). MCP calls need nothing extra — `.mcp.json` already reads the same variable.
65
+
64
66
  **All four inline means this skill is already finished — stop here.** `token=`, `board=`, `org=` and `baseUrl=` arriving together is the shape a parent agent hands a subagent, and it resolves every required value in this one step. Do not walk the config file (Step 1), do not ask anything (Step 2), do not persist (Step 3), and do not make the verify call (Step 4): the parent resolved these values against this board and verified them there, so a subagent verifying them again learns nothing it wasn't just told and pays a round trip for it. Step 3.5 is a no-op too whenever `.mcp.json` already carries an `eventmodelers` entry — read the file, don't rewrite it, and don't re-register a server the session is already connected to. Print `Connected — board <BOARD_ID>` and return to the skill that invoked you.
65
67
 
66
68
  ---