@eventmodelers/cli 1.0.88 → 1.0.89
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 +65 -32
- 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/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.89",
|
|
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
|
|
|
@@ -275,6 +276,15 @@ async function handleSliceChanged(payload, cfg, kitDir, queueAllStatuses) {
|
|
|
275
276
|
await retryOn401('fetchAndPersistSlices', () => fetchAndPersistSlices(cfg, kitDir)).catch((err) =>
|
|
276
277
|
console.error('[agent] Slice persist error:', err),
|
|
277
278
|
);
|
|
279
|
+
// This agent's own status writes (claim → InProgress, → Done, → Blocked) come straight back
|
|
280
|
+
// as slice:changed. Queuing them only buys a turn in which the agent reads its own echo and
|
|
281
|
+
// skips it. Attribution decides, never a guess: only an agentId equal to ours is dropped —
|
|
282
|
+
// a person's edit or another agent's (different id), or an event with no attribution at all,
|
|
283
|
+
// is still queued, so a missing field degrades to the old behaviour rather than losing work.
|
|
284
|
+
if (payload?.agentId && cfg.agentId && payload.agentId === cfg.agentId) {
|
|
285
|
+
console.log(`[agent] Own write — not queued (slice="${payload.sliceTitle}" status="${payload.sliceStatus}")`);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
278
288
|
// Planned slices are handled by onPlannedSlice directly — no task needed.
|
|
279
289
|
// queueAllStatuses opts out of that split entirely (e.g. bridge has no
|
|
280
290
|
// onPlannedSlice consumer, so a lingering Planned slice would otherwise
|
|
@@ -321,11 +331,33 @@ async function startRealtimeAgent(cfg, kitDir, { agentType = 'BUILD', queueAllSt
|
|
|
321
331
|
|
|
322
332
|
// A subscribe that errors on a stale token needs a fresh token AND a new join
|
|
323
333
|
// attempt — setAuth alone doesn't re-join a channel that already errored out.
|
|
324
|
-
//
|
|
325
|
-
//
|
|
334
|
+
// Retried with exponential backoff (2s → 5min) rather than a fixed delay and a cap:
|
|
335
|
+
// the cap used to leave the channel dead for good — the scheduled refresh only calls
|
|
336
|
+
// setAuth, it never re-joins — while the backoff alone keeps a genuinely revoked
|
|
337
|
+
// access from hammering the platform. A revoked token ends the process anyway, via
|
|
338
|
+
// retryOn401 inside refreshToken.
|
|
326
339
|
let channelErrorStreak = 0;
|
|
340
|
+
let resubscribeTimer = null;
|
|
341
|
+
const onChannelFailure = (reason) => {
|
|
342
|
+
// One failure can be reported more than once (a channel erroring while its retry is
|
|
343
|
+
// already scheduled); a second resubscribe on top would join the channel twice.
|
|
344
|
+
if (resubscribeTimer) return;
|
|
345
|
+
channelErrorStreak += 1;
|
|
346
|
+
const delay = backoffMs(channelErrorStreak, { baseMs: 2_000, capMs: 5 * 60_000 });
|
|
347
|
+
console.warn(`[agent] ${ts()} Channel "${channelName}": ${reason} (attempt ${channelErrorStreak}) — resubscribing in ${Math.round(delay / 1000)}s`);
|
|
348
|
+
resubscribeTimer = setTimeout(async () => {
|
|
349
|
+
try {
|
|
350
|
+
await refreshToken(`channel ${reason}`);
|
|
351
|
+
} catch {
|
|
352
|
+
// Logged by refreshToken. Rejoining on the old token is still worth a try —
|
|
353
|
+
// if it fails too, that's just the next failure, with a longer delay.
|
|
354
|
+
}
|
|
355
|
+
resubscribeTimer = null;
|
|
356
|
+
subscribeChannel();
|
|
357
|
+
}, delay);
|
|
358
|
+
};
|
|
327
359
|
const subscribeChannel = () => {
|
|
328
|
-
realtime.subscribe(
|
|
360
|
+
Promise.resolve(realtime.subscribe(
|
|
329
361
|
channelName,
|
|
330
362
|
{
|
|
331
363
|
// A kill names exactly one agent: {type: 'kill', id: '<agentId>', instruction: 'exit'}.
|
|
@@ -340,32 +372,22 @@ async function startRealtimeAgent(cfg, kitDir, { agentType = 'BUILD', queueAllSt
|
|
|
340
372
|
},
|
|
341
373
|
'slice:changed': (payload) => handleSliceChanged(payload, cfg, kitDir, queueAllStatuses),
|
|
342
374
|
},
|
|
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)`);
|
|
375
|
+
(status) => {
|
|
376
|
+
if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') return onChannelFailure(status);
|
|
377
|
+
if (status !== 'SUBSCRIBED' || channelErrorStreak === 0) {
|
|
378
|
+
console.log(`[agent] ${ts()} Channel "${channelName}": ${status}`);
|
|
357
379
|
return;
|
|
358
380
|
}
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
console.error(`[agent] ${ts()}
|
|
366
|
-
|
|
381
|
+
console.log(`[agent] ${ts()} Channel "${channelName}": ${status} — recovered after ${channelErrorStreak} failed attempt(s)`);
|
|
382
|
+
channelErrorStreak = 0;
|
|
383
|
+
// Broadcasts sent while the channel was down are gone. Re-read the slices once so a
|
|
384
|
+
// slice set to Planned during the gap still gets built — .slices/ is what ralphLoop
|
|
385
|
+
// picks Planned work from.
|
|
386
|
+
retryOn401('fetchAndPersistSlices (after reconnect)', () => fetchAndPersistSlices(cfg, kitDir)).catch((err) =>
|
|
387
|
+
console.error(`[agent] ${ts()} Slice re-fetch after reconnect failed:`, err),
|
|
388
|
+
);
|
|
367
389
|
},
|
|
368
|
-
);
|
|
390
|
+
)).catch((err) => onChannelFailure(`subscribe failed: ${err.message}`));
|
|
369
391
|
};
|
|
370
392
|
subscribeChannel();
|
|
371
393
|
|
|
@@ -531,15 +553,26 @@ async function blockStuckSlice(kitDir, cfg, credentialed, planned, attempts) {
|
|
|
531
553
|
console.error(`[ralph] ${reason} Marked "${planned.title}" (id=${planned.id}) as Blocked — moving on.`);
|
|
532
554
|
}
|
|
533
555
|
|
|
556
|
+
// Returns true once fn succeeds; false for a turn that timed out (see lib/turn.js). A timeout
|
|
557
|
+
// is not retried in place: re-running the same hung turn every 60s would spin forever without
|
|
558
|
+
// the stuck-slice guard ever seeing it, so it goes back to ralphLoop, which recounts the slice.
|
|
559
|
+
// Any other failure is retried in place with exponential backoff (30s → 10min): a harness
|
|
560
|
+
// that fails fast (API outage, rate limit, broken install) used to be relaunched every 60s
|
|
561
|
+
// for as long as it stayed broken.
|
|
534
562
|
async function runWithRetry(label, fn) {
|
|
535
|
-
|
|
563
|
+
for (let attempt = 1; ; attempt++) {
|
|
536
564
|
try {
|
|
537
565
|
console.log(`[ralph] ${label}`);
|
|
538
566
|
await fn();
|
|
539
|
-
return;
|
|
567
|
+
return true;
|
|
540
568
|
} catch (err) {
|
|
541
|
-
|
|
542
|
-
|
|
569
|
+
if (err?.timedOut) {
|
|
570
|
+
console.error(`[ralph] ${err.message} — back to the loop`);
|
|
571
|
+
return false;
|
|
572
|
+
}
|
|
573
|
+
const delay = backoffMs(attempt, { baseMs: 30_000, capMs: 10 * 60_000 });
|
|
574
|
+
console.error(`[ralph] Error (attempt ${attempt}) — retrying in ${Math.round(delay / 1000)}s:`, err.message);
|
|
575
|
+
await sleep(delay);
|
|
543
576
|
}
|
|
544
577
|
}
|
|
545
578
|
}
|
|
@@ -580,7 +613,7 @@ async function ralphLoop(kitDir, cfg, onTask, onPlannedSlice, localOnly = false)
|
|
|
580
613
|
}
|
|
581
614
|
|
|
582
615
|
const prompt = readFileSync(backendPromptFile, 'utf-8');
|
|
583
|
-
await runWithRetry(`onPlannedSlice: building slice "${planned.title}"...`, () => onPlannedSlice(prompt, {
|
|
616
|
+
const built = await runWithRetry(`onPlannedSlice: building slice "${planned.title}"...`, () => onPlannedSlice(prompt, {
|
|
584
617
|
sliceId: planned.id,
|
|
585
618
|
sliceTitle: planned.title,
|
|
586
619
|
context: planned.ctx,
|
|
@@ -588,7 +621,7 @@ async function ralphLoop(kitDir, cfg, onTask, onPlannedSlice, localOnly = false)
|
|
|
588
621
|
boardId: cfg.boardId ?? null,
|
|
589
622
|
attempt: stuckSlice.count,
|
|
590
623
|
}));
|
|
591
|
-
console.log(`[ralph] Slice build complete — waiting for next slice`);
|
|
624
|
+
if (built) console.log(`[ralph] Slice build complete — waiting for next slice`);
|
|
592
625
|
if (credentialed) await fetchAndPersistSlices(cfg, kitDir).catch(() => {});
|
|
593
626
|
didWork = true;
|
|
594
627
|
}
|
|
@@ -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 });
|
|
@@ -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
|
---
|