@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.
- package/README.md +2 -0
- package/RELEASE_NOTES.md +3 -0
- package/cli.js +308 -23
- package/lib/update-check.js +67 -0
- package/package.json +4 -1
- package/shared/build-kit/lib/adapters/pocketbase-realtime-adapter.js +11 -3
- package/shared/build-kit/lib/adapters/realtime-adapter.js +3 -1
- package/shared/build-kit/lib/adapters/supabase-realtime-adapter.js +14 -3
- package/shared/build-kit/lib/ralph.js +136 -59
- package/shared/build-kit/lib/redact.js +36 -0
- package/shared/build-kit/lib/turn.js +56 -20
- package/shared/build-kit/ralph-claude.js +7 -19
- package/shared/build-kit/ralph-exec.js +5 -9
- package/shared/build-kit/realtime-agent.js +4 -2
- package/shared/skills/connect/SKILL.md +14 -9
- package/shared/skills/learn-eventmodelers-api/SKILL.md +61 -24
- package/shared/skills/load-slice/SKILL.md +3 -1
- package/stacks/modeling-kit/templates/.claude/skills/add-next-slice/SKILL.md +4 -4
- package/stacks/modeling-kit/templates/.claude/skills/analyze-existing-model/SKILL.md +18 -9
- package/stacks/modeling-kit/templates/.claude/skills/analyze-existing-model/references/api-fallback.md +31 -11
- package/stacks/modeling-kit/templates/.claude/skills/attributes/SKILL.md +16 -13
- package/stacks/modeling-kit/templates/.claude/skills/attributes/references/api-fallback.md +3 -1
- package/stacks/modeling-kit/templates/.claude/skills/discover-storyboard/SKILL.md +6 -6
- package/stacks/modeling-kit/templates/.claude/skills/discover-storyboard/references/api-fallback.md +3 -1
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-brainstorming-events/SKILL.md +4 -4
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-brainstorming-events/references/api-fallback.md +4 -4
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-checking-completeness/SKILL.md +8 -12
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-checking-completeness/references/api-fallback.md +16 -6
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-elaborating-scenarios/SKILL.md +3 -3
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-elaborating-scenarios/references/api-fallback.md +1 -1
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-identifying-inputs/SKILL.md +2 -2
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-identifying-inputs/references/api-fallback.md +4 -4
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-identifying-outputs/SKILL.md +4 -4
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-identifying-outputs/references/api-fallback.md +4 -4
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-orchestrating-event-modeling/SKILL.md +3 -3
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-orchestrating-event-modeling/references/api-fallback.md +3 -1
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-plotting-events/SKILL.md +2 -2
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-plotting-events/references/api-fallback.md +1 -1
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-slicing-event-models/SKILL.md +3 -3
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-slicing-event-models/references/api-fallback.md +12 -4
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-storyboarding-events/SKILL.md +7 -6
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-storyboarding-events/references/api-fallback.md +4 -3
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-validating-event-models/SKILL.md +22 -6
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-validating-event-models/references/api-fallback.md +12 -3
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-validating-event-models-checklist/SKILL.md +18 -6
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-validating-event-models-checklist/references/api-fallback.md +12 -3
- package/stacks/modeling-kit/templates/.claude/skills/examples/SKILL.md +2 -2
- package/stacks/modeling-kit/templates/.claude/skills/examples/references/api-fallback.md +20 -14
- package/stacks/modeling-kit/templates/.claude/skills/place-element/SKILL.md +12 -12
- package/stacks/modeling-kit/templates/.claude/skills/place-element/references/api-fallback.md +6 -4
- package/stacks/modeling-kit/templates/.claude/skills/storyboard/SKILL.md +4 -4
- package/stacks/modeling-kit/templates/.claude/skills/storyboard/references/api-fallback.md +2 -2
- package/stacks/modeling-kit/templates/.claude/skills/timeline/SKILL.md +10 -10
- package/stacks/modeling-kit/templates/.claude/skills/timeline/references/api-fallback.md +4 -4
- package/stacks/modeling-kit/templates/.claude/skills/wdyt/SKILL.md +11 -24
- package/stacks/modeling-kit/templates/kit/AGENTS.md +3 -3
- package/stacks/modeling-kit/templates/kit/CLAUDE-STANDALONE.md +56 -10
- package/stacks/modeling-kit/templates/kit/CLAUDE.md +110 -44
- package/stacks/react/templates/build-kit/lib/backend-prompt.md +106 -102
- 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
|
-
|
|
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
|
|
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
|
-
|
|
6
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
|
265
|
-
const
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
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
|
|
280
|
-
//
|
|
281
|
-
// is
|
|
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
|
-
|
|
296
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
333
|
-
//
|
|
334
|
-
//
|
|
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
|
|
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 (
|
|
412
|
+
if (resubscribePending) return;
|
|
413
|
+
resubscribePending = true;
|
|
343
414
|
channelErrorStreak += 1;
|
|
344
|
-
const delay = backoffMs(channelErrorStreak,
|
|
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
|
-
|
|
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
|
-
|
|
424
|
+
resubscribePending = false;
|
|
354
425
|
subscribeChannel();
|
|
355
426
|
}, delay);
|
|
356
427
|
};
|
|
357
428
|
const subscribeChannel = () => {
|
|
358
|
-
|
|
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
|
-
|
|
455
|
+
fetchAndPersistSlices(cfg, kitDir).catch((err) =>
|
|
385
456
|
console.error(`[agent] ${ts()} Slice re-fetch after reconnect failed:`, err),
|
|
386
457
|
);
|
|
387
458
|
},
|
|
388
|
-
)
|
|
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
|
-
|
|
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
|
|
558
|
-
//
|
|
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
|
|
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,
|
|
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
|
|
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 {
|
|
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
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
|
|
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
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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(
|
|
99
|
+
if (block.type === 'text' && block.text && verbose) console.log(block.text);
|
|
113
100
|
if (block.type === 'tool_use') {
|
|
114
|
-
if (verbose) console.log(`→ ${
|
|
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
|
-
|
|
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
|
});
|