@eventmodelers/cli 1.0.90 → 1.0.92

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.
Files changed (60) hide show
  1. package/README.md +2 -0
  2. package/RELEASE_NOTES.md +3 -0
  3. package/cli.js +308 -23
  4. package/lib/update-check.js +67 -0
  5. package/package.json +4 -1
  6. package/shared/build-kit/lib/adapters/pocketbase-realtime-adapter.js +11 -3
  7. package/shared/build-kit/lib/adapters/realtime-adapter.js +3 -1
  8. package/shared/build-kit/lib/adapters/supabase-realtime-adapter.js +14 -3
  9. package/shared/build-kit/lib/ralph.js +136 -59
  10. package/shared/build-kit/lib/redact.js +36 -0
  11. package/shared/build-kit/lib/turn.js +56 -20
  12. package/shared/build-kit/ralph-claude.js +7 -19
  13. package/shared/build-kit/ralph-exec.js +5 -9
  14. package/shared/build-kit/realtime-agent.js +4 -2
  15. package/shared/skills/connect/SKILL.md +14 -9
  16. package/shared/skills/learn-eventmodelers-api/SKILL.md +61 -24
  17. package/shared/skills/load-slice/SKILL.md +3 -1
  18. package/stacks/modeling-kit/templates/.claude/skills/add-next-slice/SKILL.md +4 -4
  19. package/stacks/modeling-kit/templates/.claude/skills/analyze-existing-model/SKILL.md +18 -9
  20. package/stacks/modeling-kit/templates/.claude/skills/analyze-existing-model/references/api-fallback.md +31 -11
  21. package/stacks/modeling-kit/templates/.claude/skills/attributes/SKILL.md +16 -13
  22. package/stacks/modeling-kit/templates/.claude/skills/attributes/references/api-fallback.md +3 -1
  23. package/stacks/modeling-kit/templates/.claude/skills/discover-storyboard/SKILL.md +6 -6
  24. package/stacks/modeling-kit/templates/.claude/skills/discover-storyboard/references/api-fallback.md +3 -1
  25. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-brainstorming-events/SKILL.md +4 -4
  26. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-brainstorming-events/references/api-fallback.md +4 -4
  27. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-checking-completeness/SKILL.md +8 -12
  28. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-checking-completeness/references/api-fallback.md +16 -6
  29. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-elaborating-scenarios/SKILL.md +3 -3
  30. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-elaborating-scenarios/references/api-fallback.md +1 -1
  31. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-identifying-inputs/SKILL.md +2 -2
  32. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-identifying-inputs/references/api-fallback.md +4 -4
  33. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-identifying-outputs/SKILL.md +4 -4
  34. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-identifying-outputs/references/api-fallback.md +4 -4
  35. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-orchestrating-event-modeling/SKILL.md +3 -3
  36. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-orchestrating-event-modeling/references/api-fallback.md +3 -1
  37. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-plotting-events/SKILL.md +2 -2
  38. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-plotting-events/references/api-fallback.md +1 -1
  39. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-slicing-event-models/SKILL.md +3 -3
  40. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-slicing-event-models/references/api-fallback.md +12 -4
  41. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-storyboarding-events/SKILL.md +7 -6
  42. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-storyboarding-events/references/api-fallback.md +4 -3
  43. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-validating-event-models/SKILL.md +22 -6
  44. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-validating-event-models/references/api-fallback.md +12 -3
  45. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-validating-event-models-checklist/SKILL.md +18 -6
  46. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-validating-event-models-checklist/references/api-fallback.md +12 -3
  47. package/stacks/modeling-kit/templates/.claude/skills/examples/SKILL.md +2 -2
  48. package/stacks/modeling-kit/templates/.claude/skills/examples/references/api-fallback.md +20 -14
  49. package/stacks/modeling-kit/templates/.claude/skills/place-element/SKILL.md +12 -12
  50. package/stacks/modeling-kit/templates/.claude/skills/place-element/references/api-fallback.md +6 -4
  51. package/stacks/modeling-kit/templates/.claude/skills/storyboard/SKILL.md +4 -4
  52. package/stacks/modeling-kit/templates/.claude/skills/storyboard/references/api-fallback.md +2 -2
  53. package/stacks/modeling-kit/templates/.claude/skills/timeline/SKILL.md +10 -10
  54. package/stacks/modeling-kit/templates/.claude/skills/timeline/references/api-fallback.md +4 -4
  55. package/stacks/modeling-kit/templates/.claude/skills/wdyt/SKILL.md +11 -24
  56. package/stacks/modeling-kit/templates/kit/AGENTS.md +3 -3
  57. package/stacks/modeling-kit/templates/kit/CLAUDE-STANDALONE.md +56 -10
  58. package/stacks/modeling-kit/templates/kit/CLAUDE.md +110 -44
  59. package/stacks/react/templates/build-kit/lib/backend-prompt.md +106 -102
  60. package/stacks/react/templates/build-kit/lib/prompt.md +102 -106
@@ -16,18 +16,26 @@ const REALTIME_EVENTS_COLLECTION = 'realtime_events';
16
16
  // ourselves.
17
17
  const RECONNECT_INTERVALS_MS = [200, 300, 500, 1000, 1200, 1500, 2000];
18
18
 
19
- export async function createPocketBaseRealtimeAdapter(cfg, initialToken) {
19
+ // deps.PocketBase is injectable for tests; by default it is the pocketbase SDK's client.
20
+ export async function createPocketBaseRealtimeAdapter(cfg, initialToken, deps = {}) {
20
21
  const { EventSource } = await import('eventsource');
21
22
  if (!globalThis.EventSource) globalThis.EventSource = EventSource; // PocketBase's SDK assumes a browser-style global
22
- const { default: PocketBase } = await import('pocketbase');
23
+ const PocketBase = deps.PocketBase ?? (await import('pocketbase')).default;
23
24
  const pb = new PocketBase(cfg.pocketbaseUrl);
24
25
  pb.authStore.save(initialToken, null);
25
26
 
27
+ // subscribe() replaces the previous listener (see realtime-adapter.js).
28
+ let unsubscribe = null;
29
+
26
30
  return {
27
31
  async subscribe(topic, handlers, onStatus) {
32
+ if (unsubscribe) {
33
+ await unsubscribe().catch(() => {});
34
+ unsubscribe = null;
35
+ }
28
36
  for (let attempt = 0; ; attempt++) {
29
37
  try {
30
- await pb.collection(REALTIME_EVENTS_COLLECTION).subscribe('*', (e) => {
38
+ unsubscribe = await pb.collection(REALTIME_EVENTS_COLLECTION).subscribe('*', (e) => {
31
39
  if (e.action !== 'create' || e.record.topic !== topic) return;
32
40
  handlers[e.record.event]?.(e.record.payload);
33
41
  });
@@ -3,7 +3,9 @@
3
3
  // record-change events for self-hosted/on-prem) and hands back one common interface:
4
4
  //
5
5
  // subscribe(topic, handlers, onStatus?) — handlers is a { [eventName]: (payload) => void }
6
- // map; onStatus?.(status) fires once the subscription is live ('SUBSCRIBED').
6
+ // map; onStatus?.(status) fires once the subscription is live ('SUBSCRIBED'). Calling it
7
+ // again replaces the previous subscription — never adds to it — and a replaced one no
8
+ // longer reports status.
7
9
  // setAuth(token) — refresh the short-lived realtime token on the live connection.
8
10
  //
9
11
  // cfg.realtimeProvider absent (older servers, predating PocketBase support) means
@@ -2,20 +2,31 @@
2
2
  // broadcast channel and dispatches named broadcast events to caller-supplied handlers.
3
3
  // See realtime-adapter.js for the interface both this and the PocketBase adapter implement.
4
4
 
5
- export async function createSupabaseRealtimeAdapter(cfg, initialToken) {
6
- const { createClient } = await import('@supabase/supabase-js');
5
+ // deps.createClient is injectable for tests; by default it is supabase-js's own.
6
+ export async function createSupabaseRealtimeAdapter(cfg, initialToken, deps = {}) {
7
+ const createClient = deps.createClient ?? (await import('@supabase/supabase-js')).createClient;
7
8
  const supabase = createClient(cfg.supabaseUrl, cfg.supabaseAnonKey, {
8
9
  realtime: { params: { apikey: cfg.supabaseAnonKey } },
9
10
  });
10
11
  await supabase.realtime.setAuth(initialToken);
11
12
 
13
+ // supabase.channel(topic) hands back the existing channel for a topic it already knows, so
14
+ // re-subscribing on it would stack another set of handlers — the old channel is removed first.
15
+ let current = null;
16
+
12
17
  return {
13
18
  async subscribe(topic, handlers, onStatus) {
19
+ // Detach before removing: the removal itself makes the old channel report CLOSED, and a
20
+ // late status from a replaced channel must not count as this subscription's.
21
+ const previous = current;
22
+ current = null;
23
+ if (previous) await supabase.removeChannel(previous).catch(() => {});
14
24
  let channel = supabase.channel(topic, { config: { private: true } });
15
25
  for (const [event, handler] of Object.entries(handlers)) {
16
26
  channel = channel.on('broadcast', { event }, (msg) => handler(msg.payload));
17
27
  }
18
- channel.subscribe((status) => onStatus?.(status));
28
+ current = channel;
29
+ channel.subscribe((status) => { if (channel === current) onStatus?.(status); });
19
30
  },
20
31
  setAuth(token) {
21
32
  return supabase.realtime.setAuth(token);
@@ -6,12 +6,16 @@
6
6
  // onPlannedSlice(prompt, slice) — called when .slices/ has a "Planned" entry (omit to skip);
7
7
  // `slice` ({sliceId, sliceTitle, context, ticketNumber, boardId, attempt}) lets a runner record what building it cost.
8
8
 
9
- import { readFileSync, mkdirSync, writeFileSync, existsSync, readdirSync } from 'fs';
9
+ import { readFileSync, mkdirSync, writeFileSync, existsSync, readdirSync, renameSync } from 'fs';
10
10
  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
14
  import { backoffMs, sleep } from './backoff.js';
15
+ import { redactConsole } from './redact.js';
16
+
17
+ // Backoff for calls to the platform that failed for a reason on its side (startup, the channel).
18
+ const PLATFORM_BACKOFF = { baseMs: 2_000, capMs: 5 * 60_000 };
15
19
 
16
20
  // ── HTTP helpers ──────────────────────────────────────────────────────────────
17
21
 
@@ -22,25 +26,42 @@ class HttpError extends Error {
22
26
  }
23
27
  }
24
28
 
29
+ // Every fetchJSON call authenticates with the platform API token (x-token), and a 401 for it
30
+ // always means that token is missing, invalid or revoked (see requireApiToken on the
31
+ // platform) — no retry or wait can fix that, so the first one ends the process with a message
32
+ // saying what to fix. (The alive-ping, on the short-lived realtime token, doesn't come
33
+ // through here: a 401 there just means that token needs refreshing.)
25
34
  async function fetchJSON(url, options) {
26
35
  const res = await fetch(url, options);
36
+ if (res.status === 401) {
37
+ console.error(`[agent] 401 from ${new URL(url).pathname} — the token was rejected: ${await res.text()}`);
38
+ console.error('[agent] Check the token in .eventmodelers/config.json (or EVENTMODELERS_TOKEN) — shutting down');
39
+ process.exit(1);
40
+ }
27
41
  if (!res.ok) throw new HttpError(res.status, await res.text());
28
42
  return res.json();
29
43
  }
30
44
 
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);
45
+ // Startup can't wait for a person to notice a platform blip: a 429, a 5xx or a request that
46
+ // never got an answer is retried with backoff until it goes through. Any other 4xx is a real
47
+ // answer and goes straight back (a 401 has already ended the process in fetchJSON).
48
+ function isTransient(err) {
49
+ if (err instanceof HttpError) return err.status === 429 || err.status >= 500;
50
+ // fetch() rejects with a TypeError when there's no response at all (DNS, refused, reset),
51
+ // and with a TimeoutError/AbortError from an AbortSignal.timeout.
52
+ return err instanceof TypeError || err?.name === 'TimeoutError' || err?.name === 'AbortError';
53
+ }
54
+
55
+ async function retryTransient(label, fn, { sleepFn = sleep } = {}) {
56
+ for (let attempt = 1; ; attempt++) {
57
+ try {
58
+ return await fn();
59
+ } catch (err) {
60
+ if (!isTransient(err)) throw err;
61
+ const delay = backoffMs(attempt, PLATFORM_BACKOFF);
62
+ console.warn(`[agent] ${label} failed (attempt ${attempt}: ${err.message}) — retrying in ${Math.round(delay / 1000)}s`);
63
+ await sleepFn(delay);
42
64
  }
43
- throw err;
44
65
  }
45
66
  }
46
67
 
@@ -259,30 +280,79 @@ async function fetchAndPersistSlices(cfg, kitDir) {
259
280
  console.log(`[agent] Persisted ${slices.length} slice(s)`);
260
281
  }
261
282
 
262
- async function writeTask(payload, kitDir) {
283
+ // tasks.json has two writers: this process, queueing slice changes, and the agent, removing
284
+ // the task it just handled (as each stack's prompt.md tells it to). An unlocked read-modify-
285
+ // write from both sides at once lost whichever update landed first. So the two never overlap:
286
+ // while an onTask turn runs (see inTurn), a change is held here instead — latest per slice —
287
+ // and written the moment the turn ends. Every write goes through a temp file and a rename,
288
+ // so a reader never sees a half-written file either.
289
+ let heldTasks = null; // sliceId → payload while an onTask turn runs, null otherwise
290
+
291
+ function writeTasksAtomically(tasksPath, tasks) {
292
+ const tmp = `${tasksPath}.${process.pid}.tmp`;
293
+ writeFileSync(tmp, JSON.stringify(tasks, null, 2), 'utf-8');
294
+ renameSync(tmp, tasksPath);
295
+ }
296
+
297
+ function readTasks(tasksPath) {
298
+ if (!existsSync(tasksPath)) return [];
299
+ try {
300
+ const tasks = JSON.parse(readFileSync(tasksPath, 'utf-8'));
301
+ return Array.isArray(tasks) ? tasks : [];
302
+ } catch {
303
+ return []; // the agent left it unparseable — start over rather than wedge the queue
304
+ }
305
+ }
306
+
307
+ function appendTasks(kitDir, payloads) {
263
308
  const tasksPath = join(kitDir, 'tasks.json');
264
- const existing = existsSync(tasksPath) ? JSON.parse(readFileSync(tasksPath, 'utf-8')) : [];
265
- const filtered = existing.filter(t => t.payload?.sliceId !== payload.sliceId);
266
- const task = { id: randomUUID(), createdAt: new Date().toISOString(), payload };
267
- filtered.push(task);
268
- writeFileSync(tasksPath, JSON.stringify(filtered, null, 2), 'utf-8');
309
+ const replaced = new Set(payloads.map((p) => p.sliceId));
310
+ const tasks = readTasks(tasksPath).filter((t) => !replaced.has(t.payload?.sliceId));
311
+ for (const payload of payloads) tasks.push({ id: randomUUID(), createdAt: new Date().toISOString(), payload });
312
+ writeTasksAtomically(tasksPath, tasks);
313
+ }
314
+
315
+ async function writeTask(payload, kitDir) {
316
+ if (heldTasks) {
317
+ heldTasks.set(payload.sliceId, payload);
318
+ console.log(`[agent] Task held until the current turn ends — slice="${payload.sliceTitle}" status="${payload.sliceStatus}"`);
319
+ return;
320
+ }
321
+ appendTasks(kitDir, [payload]);
269
322
  console.log(`[agent] Task written — slice="${payload.sliceTitle}" status="${payload.sliceStatus}"`);
270
323
  }
271
324
 
325
+ // Runs one agent turn that may rewrite tasks.json, keeping this process's own writes out of
326
+ // its way; whatever arrived meanwhile is written as soon as it's over, success or not.
327
+ async function inTurn(kitDir, fn) {
328
+ heldTasks = new Map();
329
+ try {
330
+ return await fn();
331
+ } finally {
332
+ const payloads = [...heldTasks.values()];
333
+ heldTasks = null;
334
+ if (payloads.length > 0) {
335
+ appendTasks(kitDir, payloads);
336
+ console.log(`[agent] Wrote ${payloads.length} task(s) held during the turn`);
337
+ }
338
+ }
339
+ }
340
+
272
341
  async function handleSliceChanged(payload, cfg, kitDir, queueAllStatuses) {
273
342
  console.log(`[agent] slice:changed — slice="${payload.sliceTitle}" status="${payload.sliceStatus}"`);
274
- await exitOn401('fetchAndPersistSlices', () => fetchAndPersistSlices(cfg, kitDir)).catch((err) =>
275
- console.error('[agent] Slice persist error:', err),
276
- );
277
343
  // This agent's own status writes (claim → InProgress, → Done, → Blocked) come straight back
278
344
  // 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.
345
+ // skips it, and re-reading the slices for them is wasted too: ralphLoop re-reads them right
346
+ // after every turn anyway. Attribution decides, never a guess: only an agentId equal to ours
347
+ // is dropped — a person's edit or another agent's, or an event with no attribution at all,
348
+ // is still handled, so a missing field degrades to queuing rather than losing work.
282
349
  if (payload?.agentId && cfg.agentId && payload.agentId === cfg.agentId) {
283
350
  console.log(`[agent] Own write — not queued (slice="${payload.sliceTitle}" status="${payload.sliceStatus}")`);
284
351
  return;
285
352
  }
353
+ await fetchAndPersistSlices(cfg, kitDir).catch((err) =>
354
+ console.error('[agent] Slice persist error:', err),
355
+ );
286
356
  // Planned slices are handled by onPlannedSlice directly — no task needed.
287
357
  // queueAllStatuses opts out of that split entirely (e.g. bridge has no
288
358
  // onPlannedSlice consumer, so a lingering Planned slice would otherwise
@@ -292,15 +362,17 @@ async function handleSliceChanged(payload, cfg, kitDir, queueAllStatuses) {
292
362
  }
293
363
  }
294
364
 
295
- async function startRealtimeAgent(cfg, kitDir, { agentType = 'BUILD', queueAllStatuses = false } = {}) {
296
- let realtimeToken = await exitOn401('getRealtimeToken', () => getRealtimeToken(cfg));
365
+ // createAdapter is the realtime transport factory — injectable so the channel handling can be
366
+ // tested against a fake transport.
367
+ async function startRealtimeAgent(cfg, kitDir, { agentType = 'BUILD', queueAllStatuses = false, createAdapter = createRealtimeAdapter } = {}) {
368
+ let realtimeToken = await retryTransient('getRealtimeToken', () => getRealtimeToken(cfg));
297
369
 
298
- await exitOn401('fetchAndPersistSlices', () => fetchAndPersistSlices(cfg, kitDir)).catch((err) =>
370
+ await fetchAndPersistSlices(cfg, kitDir).catch((err) =>
299
371
  console.error('[agent] Initial slice fetch error:', err),
300
372
  );
301
373
 
302
374
  const channelName = `board:${cfg.boardId}-slicechanged`;
303
- const realtime = await createRealtimeAdapter(cfg, realtimeToken);
375
+ const realtime = await createAdapter(cfg, realtimeToken);
304
376
 
305
377
  // Shared by the scheduled timer, a CHANNEL_ERROR/TIMED_OUT subscribe status, and a
306
378
  // 401 from the alive-ping — whichever notices the token is bad first wins; the rest
@@ -313,7 +385,7 @@ async function startRealtimeAgent(cfg, kitDir, { agentType = 'BUILD', queueAllSt
313
385
  console.log(`[agent] ${ts()} Refreshing realtime token (reason: ${reason})...`);
314
386
  refreshing = (async () => {
315
387
  try {
316
- realtimeToken = await exitOn401('getRealtimeToken (refresh)', () => getRealtimeToken(cfg));
388
+ realtimeToken = await getRealtimeToken(cfg);
317
389
  await realtime.setAuth(realtimeToken);
318
390
  console.log(`[agent] ${ts()} Token refreshed (reason: ${reason}, took ${Date.now() - startedAt}ms)`);
319
391
  } catch (err) {
@@ -328,34 +400,33 @@ async function startRealtimeAgent(cfg, kitDir, { agentType = 'BUILD', queueAllSt
328
400
  };
329
401
 
330
402
  // A subscribe that errors on a stale token needs a fresh token AND a new join
331
- // attempt — setAuth alone doesn't re-join a channel that already errored out.
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.
403
+ // attempt — setAuth alone doesn't re-join a channel that already errored out, and nothing
404
+ // else ever re-joins it, so this retries for as long as it takes, with backoff so a
405
+ // lasting failure doesn't hammer the platform. (A revoked token ends the process anyway:
406
+ // refreshToken's fetch gets the 401.)
337
407
  let channelErrorStreak = 0;
338
- let resubscribeTimer = null;
408
+ let resubscribePending = false;
339
409
  const onChannelFailure = (reason) => {
340
410
  // One failure can be reported more than once (a channel erroring while its retry is
341
411
  // already scheduled); a second resubscribe on top would join the channel twice.
342
- if (resubscribeTimer) return;
412
+ if (resubscribePending) return;
413
+ resubscribePending = true;
343
414
  channelErrorStreak += 1;
344
- const delay = backoffMs(channelErrorStreak, { baseMs: 2_000, capMs: 5 * 60_000 });
415
+ const delay = backoffMs(channelErrorStreak, PLATFORM_BACKOFF);
345
416
  console.warn(`[agent] ${ts()} Channel "${channelName}": ${reason} (attempt ${channelErrorStreak}) — resubscribing in ${Math.round(delay / 1000)}s`);
346
- resubscribeTimer = setTimeout(async () => {
417
+ setTimeout(async () => {
347
418
  try {
348
419
  await refreshToken(`channel ${reason}`);
349
420
  } catch {
350
421
  // Logged by refreshToken. Rejoining on the old token is still worth a try —
351
422
  // if it fails too, that's just the next failure, with a longer delay.
352
423
  }
353
- resubscribeTimer = null;
424
+ resubscribePending = false;
354
425
  subscribeChannel();
355
426
  }, delay);
356
427
  };
357
428
  const subscribeChannel = () => {
358
- Promise.resolve(realtime.subscribe(
429
+ realtime.subscribe(
359
430
  channelName,
360
431
  {
361
432
  // A kill names exactly one agent: {type: 'kill', id: '<agentId>', instruction: 'exit'}.
@@ -381,11 +452,11 @@ async function startRealtimeAgent(cfg, kitDir, { agentType = 'BUILD', queueAllSt
381
452
  // Broadcasts sent while the channel was down are gone. Re-read the slices once so a
382
453
  // slice set to Planned during the gap still gets built — .slices/ is what ralphLoop
383
454
  // picks Planned work from.
384
- exitOn401('fetchAndPersistSlices (after reconnect)', () => fetchAndPersistSlices(cfg, kitDir)).catch((err) =>
455
+ fetchAndPersistSlices(cfg, kitDir).catch((err) =>
385
456
  console.error(`[agent] ${ts()} Slice re-fetch after reconnect failed:`, err),
386
457
  );
387
458
  },
388
- )).catch((err) => onChannelFailure(`subscribe failed: ${err.message}`));
459
+ ).catch((err) => onChannelFailure(`subscribe failed: ${err.message}`));
389
460
  };
390
461
  subscribeChannel();
391
462
 
@@ -424,14 +495,7 @@ async function startRealtimeAgent(cfg, kitDir, { agentType = 'BUILD', queueAllSt
424
495
  // ── Ralph loop ────────────────────────────────────────────────────────────────
425
496
 
426
497
  function hasPendingTasks(kitDir) {
427
- const tasksPath = join(kitDir, 'tasks.json');
428
- if (!existsSync(tasksPath)) return false;
429
- try {
430
- const tasks = JSON.parse(readFileSync(tasksPath, 'utf-8'));
431
- return Array.isArray(tasks) && tasks.length > 0;
432
- } catch {
433
- return false;
434
- }
498
+ return readTasks(join(kitDir, 'tasks.json')).length > 0;
435
499
  }
436
500
 
437
501
  function readCurrentContext(kitDir) {
@@ -554,9 +618,8 @@ async function blockStuckSlice(kitDir, cfg, credentialed, planned, attempts) {
554
618
  // Returns true once fn succeeds; false for a turn that timed out (see lib/turn.js). A timeout
555
619
  // is not retried in place: re-running the same hung turn every 60s would spin forever without
556
620
  // 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.
621
+ // Any other failure is retried in place, with backoff (30s → 10min) so a harness that keeps
622
+ // failing fast (API outage, rate limit, broken install) isn't relaunched in a tight loop.
560
623
  async function runWithRetry(label, fn) {
561
624
  for (let attempt = 1; ; attempt++) {
562
625
  try {
@@ -592,7 +655,7 @@ async function ralphLoop(kitDir, cfg, onTask, onPlannedSlice, localOnly = false)
592
655
 
593
656
  if (credentialed && hasPendingTasks(kitDir)) {
594
657
  const prompt = readFileSync(promptFile, 'utf-8');
595
- await runWithRetry('onTask: loading slice from board...', () => onTask(prompt));
658
+ await runWithRetry('onTask: loading slice from board...', () => inTurn(kitDir, () => onTask(prompt)));
596
659
  await fetchAndPersistSlices(cfg, kitDir).catch(() => {});
597
660
  didWork = true;
598
661
  }
@@ -631,7 +694,7 @@ async function ralphLoop(kitDir, cfg, onTask, onPlannedSlice, localOnly = false)
631
694
  console.log(`[ralph] No planned slices in current context "${ctx}" — waiting. Switch context on the board to continue.`);
632
695
  lastIdleCtx = ctx;
633
696
  }
634
- await new Promise((r) => setTimeout(r, 10_000));
697
+ await sleep(10_000);
635
698
  } else {
636
699
  lastIdleCtx = undefined;
637
700
  }
@@ -640,7 +703,20 @@ async function ralphLoop(kitDir, cfg, onTask, onPlannedSlice, localOnly = false)
640
703
 
641
704
  // ── Public API ────────────────────────────────────────────────────────────────
642
705
 
643
- export { loadLocalConfig, fetchPlatformConfig, exitOn401, startRealtimeAgent };
706
+ export { HttpError, loadLocalConfig, fetchPlatformConfig, retryTransient, startRealtimeAgent, writeTask, inTurn, hasPendingTasks };
707
+
708
+ // The connect line a runner puts in front of every prompt. --local must mean zero board
709
+ // contact, so it is empty then — handed live credentials, the agent would treat itself as
710
+ // already connected and go straight to board sync. The token is named, never included: the
711
+ // prompt reaches the harness on its command line (visible to every local user in `ps`) and
712
+ // lands in session transcripts, so it carries $EVENTMODELERS_TOKEN — the runner puts the value
713
+ // in the child's env, connect treats the reference as the inline token, and a curl header
714
+ // written as "x-token: $EVENTMODELERS_TOKEN" expands in the shell.
715
+ export function connectHeader(cfg, localOnly) {
716
+ return !localOnly && cfg.boardId
717
+ ? `board=${cfg.boardId} token=$EVENTMODELERS_TOKEN org=${cfg.organizationId} baseUrl=${cfg.baseUrl}\n\n`
718
+ : '';
719
+ }
644
720
 
645
721
  // Who this process is. RALPH_AGENT_ID/RALPH_AGENT_NAME are `eventmodelers run --id/--name`,
646
722
  // passed down as env (see cli.js's run dispatcher): a per-run identity override so a second
@@ -658,6 +734,7 @@ export function resolveAgentIdentity(kitDir, agentType = 'BUILD', cfg = loadLoca
658
734
 
659
735
  export async function startRalph({ kitDir, projectDir, onTask, onPlannedSlice, agentType = 'BUILD', queueAllStatuses = false, localOnly = false }) {
660
736
  const local = loadLocalConfig(kitDir);
737
+ redactConsole([local.token]);
661
738
  Object.assign(local, resolveAgentIdentity(kitDir, agentType, local));
662
739
 
663
740
  console.log(`Ralph — kit: ${kitDir}`);
@@ -674,7 +751,7 @@ export async function startRalph({ kitDir, projectDir, onTask, onPlannedSlice, a
674
751
  return;
675
752
  }
676
753
 
677
- const cfg = await exitOn401('fetchPlatformConfig', () => fetchPlatformConfig(local));
754
+ const cfg = await retryTransient('fetchPlatformConfig', () => fetchPlatformConfig(local));
678
755
  console.log(` org=${cfg.organizationId}, board=${cfg.boardId}, base=${cfg.baseUrl}\n`);
679
756
 
680
757
  await Promise.all([
@@ -0,0 +1,36 @@
1
+ // Keeps secrets out of the loop's own log lines. Anything this process prints can end up in a
2
+ // terminal scrollback, a CI log or a `docker logs` dump: an error body that echoes a header,
3
+ // a verbose tool input with a curl command in it, a debug line with a config object. So
4
+ // rather than trusting every call site, console output is masked on the way out:
5
+ // - the platform API token, by value (it's a plain uuid, so there's no pattern for it)
6
+ // - anything JWT-shaped (eyJ….….…) — the realtime token and Supabase keys look like that
7
+ //
8
+ // Output a child process writes straight to the terminal (stdio: 'inherit') never passes
9
+ // through console here, so it isn't covered.
10
+
11
+ import { format } from 'util';
12
+
13
+ const JWT = /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g;
14
+
15
+ // A secret shorter than this would mask ordinary text (an empty or placeholder value most of all).
16
+ const MIN_SECRET_LENGTH = 8;
17
+
18
+ export function redactString(text, secrets = []) {
19
+ let out = String(text).replace(JWT, '***');
20
+ for (const secret of secrets) {
21
+ if (typeof secret === 'string' && secret.length >= MIN_SECRET_LENGTH) out = out.replaceAll(secret, '***');
22
+ }
23
+ return out;
24
+ }
25
+
26
+ // Routes console.log/info/warn/error/debug through redactString. Arguments are formatted the
27
+ // way console would format them first, so an Error or an object is masked too. Returns a
28
+ // function that puts the originals back.
29
+ export function redactConsole(secrets) {
30
+ const methods = ['log', 'info', 'warn', 'error', 'debug'];
31
+ const originals = Object.fromEntries(methods.map((m) => [m, console[m]]));
32
+ for (const m of methods) {
33
+ console[m] = (...args) => originals[m].call(console, redactString(format(...args), secrets));
34
+ }
35
+ return () => { for (const m of methods) console[m] = originals[m]; };
36
+ }
@@ -5,7 +5,8 @@
5
5
  // RALPH_TURN_TIMEOUT_MIN (env) or turnTimeoutMinutes (.eventmodelers/config.json);
6
6
  // default 60, 0 turns the cap off.
7
7
 
8
- import { execFile } from 'child_process';
8
+ import { execFileSync } from 'child_process';
9
+ import { constants as osConstants } from 'os';
9
10
 
10
11
  const KILL_GRACE_MS = 10_000;
11
12
 
@@ -15,43 +16,78 @@ export function turnTimeoutMs(cfg) {
15
16
  return Number.isFinite(minutes) && minutes > 0 ? minutes * 60_000 : 0;
16
17
  }
17
18
 
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 {
19
+ // What a timed-out turn rejects with. ralph.js's runWithRetry lets it (`timedOut`) through
20
+ // to the outer loop instead of re-running the same turn in place, so a slice that times out
21
+ // again is counted — and eventually Blocked — by the stuck-slice guard.
22
+ class TurnTimeoutError extends Error {
22
23
  constructor(label, timeoutMs) {
23
24
  super(`${label} timed out after ${Math.round(timeoutMs / 60_000)}min`);
24
25
  this.timedOut = true;
25
26
  }
26
27
  }
27
28
 
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.
29
+ // `shell: true` means proc is only the /bin/sh wrapping the real command, so its children are
30
+ // signalled first — killing just the shell would orphan the harness and leave it running. (A
31
+ // separate process group would be cleaner, but a background group reading the terminal gets
32
+ // stopped by SIGTTIN, and Ctrl-C would no longer reach it.) pkill runs synchronously so the
33
+ // shell is still alive, and its children still its own, when pkill -P looks for them.
35
34
  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();
35
+ if (viaShell && proc.pid) {
36
+ try { execFileSync('pkill', [`-${sig}`, '-P', String(proc.pid)]); } catch { /* no children left */ }
37
+ }
38
+ try { proc.kill(`SIG${sig}`); } catch { /* already gone */ }
39
+ }
40
+
41
+ // SIGTERM, then SIGKILL once the grace period is up.
42
+ function stop(turn) {
43
+ signal(turn.proc, 'TERM', turn.viaShell);
44
+ turn.killTimer = setTimeout(() => signal(turn.proc, 'KILL', turn.viaShell), KILL_GRACE_MS);
39
45
  }
40
46
 
47
+ // Stopping the loop has to stop its turn too: a SIGTERM from systemd, Docker or `kill` reaches
48
+ // this process alone (only Ctrl-C signals the whole foreground group), and node's default —
49
+ // exit on the spot — left the running agent orphaned, still editing the project. So every
50
+ // live turn is stopped the way a timeout stops it, and the process exits once they're gone.
51
+ // A second signal doesn't wait out the grace period: it SIGKILLs what's left and exits.
52
+ const liveTurns = new Set();
53
+ let exitCode = null; // set once a shutdown signal has arrived
54
+
55
+ function exitOnSignal(sig) {
56
+ const code = 128 + osConstants.signals[sig];
57
+ if (liveTurns.size === 0) process.exit(code);
58
+ if (exitCode !== null) {
59
+ for (const t of liveTurns) signal(t.proc, 'KILL', t.viaShell);
60
+ process.exit(code);
61
+ }
62
+ exitCode = code;
63
+ for (const t of liveTurns) {
64
+ t.log(`Received ${sig} — stopping ${t.label} before exiting (send it again to exit now)`);
65
+ stop(t);
66
+ }
67
+ // Backstop: a child whose close never fires mustn't keep the process up forever.
68
+ setTimeout(() => process.exit(code), KILL_GRACE_MS + 2_000);
69
+ }
70
+
71
+ for (const sig of ['SIGTERM', 'SIGINT', 'SIGHUP']) process.on(sig, () => exitOnSignal(sig));
72
+
73
+ // Returns timeoutError(): the error to reject with once the turn has closed, or null when it
74
+ // didn't hit the cap.
41
75
  export function superviseTurn(proc, { timeoutMs, label, log, viaShell = false }) {
76
+ const turn = { proc, label, log, viaShell, killTimer: null };
42
77
  let timedOut = false;
43
- let killTimer;
44
78
  const timer = timeoutMs
45
79
  ? setTimeout(() => {
46
80
  timedOut = true;
47
81
  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);
82
+ stop(turn);
50
83
  }, timeoutMs)
51
84
  : null;
85
+ liveTurns.add(turn);
52
86
  proc.on('close', () => {
53
87
  clearTimeout(timer);
54
- clearTimeout(killTimer);
88
+ clearTimeout(turn.killTimer);
89
+ liveTurns.delete(turn);
90
+ if (exitCode !== null && liveTurns.size === 0) process.exit(exitCode);
55
91
  });
56
- return { timedOut: () => timedOut };
92
+ return { timeoutError: () => (timedOut ? new TurnTimeoutError(label, timeoutMs) : null) };
57
93
  }
@@ -2,9 +2,9 @@
2
2
  // Ralph loop + realtime agent using Claude Code as the executor.
3
3
  // Usage: node ralph-claude.js [project_dir]
4
4
 
5
- import { startRalph, loadLocalConfig, resolveAgentIdentity } from './lib/ralph.js';
5
+ import { startRalph, loadLocalConfig, resolveAgentIdentity, connectHeader } from './lib/ralph.js';
6
6
  import { createSliceTracer, usageFromClaudeResult } from './lib/tracing.js';
7
- import { turnTimeoutMs, superviseTurn, TurnTimeoutError } from './lib/turn.js';
7
+ import { turnTimeoutMs, superviseTurn } from './lib/turn.js';
8
8
  import { spawn } from 'child_process';
9
9
  import { dirname, join, resolve } from 'path';
10
10
  import { fileURLToPath } from 'url';
@@ -31,21 +31,8 @@ const tracer = createSliceTracer({
31
31
  log: (line) => console.log(`[ralph] ${line}`),
32
32
  enabled: tracingOn,
33
33
  });
34
- // --local must mean zero board contact — never hand Claude live board
35
- // credentials via the inline header, even if config.json has them, or it'll
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.
41
- const inlineHeader = !localOnly && cfg.boardId
42
- ? `board=${cfg.boardId} token=$EVENTMODELERS_TOKEN org=${cfg.organizationId} baseUrl=${cfg.baseUrl}\n\n`
43
- : '';
34
+ const inlineHeader = connectHeader(cfg, localOnly);
44
35
  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);
48
-
49
36
  // --verbose here (set via `eventmodelers run --verbose`, passed down as RALPH_VERBOSE)
50
37
  // logs full tool input and assistant reasoning text; the default (condensed) mode logs
51
38
  // only the high-level step — a skill name, or a bare tool name — mirroring `run --modeling`'s
@@ -109,9 +96,9 @@ function runClaude(prompt, slice = null) {
109
96
 
110
97
  if (msg.type === 'assistant') {
111
98
  for (const block of msg.message?.content ?? []) {
112
- if (block.type === 'text' && block.text && verbose) console.log(redact(block.text));
99
+ if (block.type === 'text' && block.text && verbose) console.log(block.text);
113
100
  if (block.type === 'tool_use') {
114
- if (verbose) console.log(`→ ${redact(describeToolUse(block))}`);
101
+ if (verbose) console.log(`→ ${describeToolUse(block)}`);
115
102
  else if (block.name === 'Skill') console.log(`→ Skill: ${block.input?.skill ?? ''}`);
116
103
  else console.log(`→ ${block.name}`);
117
104
  }
@@ -124,7 +111,8 @@ function runClaude(prompt, slice = null) {
124
111
  });
125
112
 
126
113
  proc.on('close', (code) => {
127
- if (turn.timedOut()) reject(new TurnTimeoutError('Claude turn', timeoutMs));
114
+ const timeout = turn.timeoutError();
115
+ if (timeout) reject(timeout);
128
116
  else if (code === 0) resolve();
129
117
  else reject(new Error(`Claude exited ${code}`));
130
118
  });