@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 +2 -0
- package/package.json +1 -1
- package/shared/build-kit/lib/backoff.js +12 -0
- package/shared/build-kit/lib/ralph.js +83 -52
- package/shared/build-kit/lib/turn.js +57 -0
- package/shared/build-kit/ralph-claude.js +18 -4
- package/shared/build-kit/ralph-exec.js +7 -2
- package/shared/build-kit/realtime-agent.js +2 -2
- package/shared/skills/connect/SKILL.md +2 -0
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.
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
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
|
|
296
|
+
let realtimeToken = await exitOn401('getRealtimeToken', () => getRealtimeToken(cfg));
|
|
289
297
|
|
|
290
|
-
await
|
|
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
|
|
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
|
-
//
|
|
325
|
-
//
|
|
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
|
-
|
|
344
|
-
if (status
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
console.error(`[agent] ${ts()}
|
|
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
|
-
|
|
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
|
-
|
|
542
|
-
|
|
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,
|
|
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
|
|
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=$
|
|
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) =>
|
|
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=$
|
|
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,
|
|
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
|
|
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
|
---
|