@ethlete/agent-rules 0.1.0-next.27 → 0.1.0-next.28

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 (35) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/content/hooks/context-warning.py +32 -3
  3. package/content/mods/.claude-plugin/marketplace.json +11 -0
  4. package/content/mods/plugins/e2e-progress/.claude-plugin/plugin.json +6 -0
  5. package/content/mods/plugins/e2e-progress/hooks/hooks.json +1 -0
  6. package/content/mods/plugins/e2e-progress/hooks/register.tsx +442 -0
  7. package/content/mods/plugins/e2e-progress/tsconfig.json +3 -0
  8. package/content/mods/plugins/e2e-progress/types/index.d.ts +23 -0
  9. package/content/rules/agent-sessions-wake-up.md +11 -0
  10. package/content/skills/agent-sessions/SKILL.md +81 -0
  11. package/content/skills/agent-sessions/agent-sessions.mjs +436 -0
  12. package/content/skills/design-exploration/SKILL.md +3 -1
  13. package/content/skills/e2e-triage/SKILL.md +80 -0
  14. package/content/skills/handoff/SKILL.md +48 -2
  15. package/content/skills/sdk-update/SKILL.md +7 -0
  16. package/package.json +1 -1
  17. package/src/index.js +7 -0
  18. package/src/index.js.map +1 -1
  19. package/src/lib/config.d.ts +2 -0
  20. package/src/lib/config.js +5 -3
  21. package/src/lib/config.js.map +1 -1
  22. package/src/lib/plan.js +4 -0
  23. package/src/lib/plan.js.map +1 -1
  24. package/src/lib/sessions-command.d.ts +4 -0
  25. package/src/lib/sessions-command.js +16 -0
  26. package/src/lib/sessions-command.js.map +1 -0
  27. package/src/lib/targets/claude-hooks.d.ts +2 -0
  28. package/src/lib/targets/claude-hooks.js +4 -1
  29. package/src/lib/targets/claude-hooks.js.map +1 -1
  30. package/src/lib/targets/claude-mods.d.ts +20 -0
  31. package/src/lib/targets/claude-mods.js +47 -0
  32. package/src/lib/targets/claude-mods.js.map +1 -0
  33. package/src/lib/targets/hooks-shared.d.ts +1 -0
  34. package/src/lib/targets/hooks-shared.js +3 -2
  35. package/src/lib/targets/hooks-shared.js.map +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # @ethlete/agent-rules
2
2
 
3
+ ## 0.1.0-next.28
4
+
5
+ ### Minor Changes
6
+
7
+ - New `mods` config key: `sync` turns on Claude Code mods that the package ships, starting with `e2e-progress`, a live Playwright run progress in the status line.
8
+ - Add the consumer skill `e2e-triage`: read the failures file of the `@ethlete/cli` failure reporter, group the failures by error, fix the largest group first, and re-run only the failed spec files.
9
+ - Add the `agent-sessions` skill and `ethlete-agents sessions`: agents in one checkout run in `tmux`, message each other, claim paths, and restart a session with a prompt. The `handoff` skill uses it to clear and resume.
10
+
11
+ ### Patch Changes
12
+
13
+ - `agent-sessions restart` refuses a session the caller does not run in, and the handoff skill says how to read the own session name.
14
+ - The `claude` and `codex` shell functions no longer switch your tmux client when a script or a one-shot flag such as `--version` calls them.
15
+ - `agent-sessions run` starts `<name>-2`, `<name>-3` and so on when the session already has a terminal attached, so a second `claude` terminal gets its own agent.
16
+ - The e2e-triage skill treats connection-refused groups on a local mock as infrastructure, and the e2e-progress mod shows the failure `detail`.
17
+ - The handoff skill counts an open item as the next task, so an agent in tmux restarts itself with it instead of handing the prompt to the user.
18
+ - A handoff self-restart now resumes with the installed skill name and keeps both the restart reason and the text after the slug.
19
+ - In an agent-sessions tmux session, the context-warning hook tells the agent to save and restart itself, not to ask the user for `/clear`.
20
+ - The `sdk-update` skill warns that after Angular 22, `a?.b` gives `undefined`, not `null`, which breaks pipes that treat the two differently.
21
+ - Shorten the mailbox wake-up prompt: it runs `npx ethlete-agents sessions inbox <name>` or the `agents:sessions` script when one exists, and the full script path otherwise.
22
+ - `et design shot --variants` takes `--round <key>` and `--variant <name>` to draw only the current round next to the merged row.
23
+ - Reject and reopen single sketch variants with `et design sketch reject|reopen` or in Studio; a rejected variant keeps its reason, origin and history and can no longer be picked or merged.
24
+ - The `agent-sessions` dispatcher waits when the prompt of an agent holds typed text or an open menu, and sends the wake-up line after the text is sent or cleared.
25
+
3
26
  ## 0.1.0-next.27
4
27
 
5
28
  ### Minor Changes
@@ -93,6 +93,7 @@ a tool batch or the end of a turn.
93
93
  import json
94
94
  import os
95
95
  import re
96
+ import subprocess
96
97
  import sys
97
98
  import tempfile
98
99
 
@@ -255,11 +256,39 @@ def handoff_skill(root):
255
256
  return "ethlete-handoff"
256
257
 
257
258
 
258
- def resolve_profile(profile, skill):
259
+ SELF_RESTART = (
260
+ "you run in an agent-sessions tmux session, so do not hand this step to the user: apply the {handoff} skill's save "
261
+ "test, save if it calls for a file, then follow its 'Clear and resume on your own' section "
262
+ "and restart this session yourself with the agent-sessions `restart` command - with no file, "
263
+ "restart with a prompt that names the next step"
264
+ )
265
+ USER_DIRECTED_KEYS = ("recommend", "suggest", "save_now", "save_at_stop")
266
+
267
+
268
+ def can_self_restart(root):
269
+ """True only in a tmux session the agent-sessions `restart` command can find and drive."""
270
+ if not os.environ.get("TMUX") or not root:
271
+ return False
272
+ if not any(
273
+ os.path.isfile(os.path.join(root, ".agents/skills", name, "agent-sessions.mjs"))
274
+ for name in ("ethlete-agent-sessions", "agent-sessions")
275
+ ):
276
+ return False
277
+ try:
278
+ session = subprocess.run(
279
+ ["tmux", "display-message", "-p", "#S"], capture_output=True, text=True, timeout=2
280
+ ).stdout.strip()
281
+ except (OSError, subprocess.SubprocessError):
282
+ return False
283
+ return session.endswith("@" + re.sub(r"[.:]", "-", os.path.basename(os.path.normpath(root))))
284
+
285
+
286
+ def resolve_profile(profile, skill, self_restart=False):
259
287
  """Fills the installed handoff skill's name into a profile's message fragments."""
288
+ resolved = {**profile, **{key: SELF_RESTART for key in USER_DIRECTED_KEYS}} if self_restart else profile
260
289
  return {
261
290
  key: value.replace("{handoff}", skill) if isinstance(value, str) else value
262
- for key, value in profile.items()
291
+ for key, value in resolved.items()
263
292
  }
264
293
 
265
294
 
@@ -821,7 +850,7 @@ def main():
821
850
  if disabled_locally(local_config):
822
851
  return
823
852
 
824
- profile = resolve_profile(profile, handoff_skill(root))
853
+ profile = resolve_profile(profile, handoff_skill(root), self_restart=can_self_restart(root))
825
854
 
826
855
  claude_subagent = agent == "claude" and bool(data.get("agent_id"))
827
856
  if claude_subagent:
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "ethlete",
3
+ "owner": { "name": "ethlete" },
4
+ "plugins": [
5
+ {
6
+ "name": "e2e-progress",
7
+ "source": "./plugins/e2e-progress",
8
+ "description": "Live Playwright e2e progress from a run log, as a status line entry and a pane"
9
+ }
10
+ ]
11
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "e2e-progress",
3
+ "version": "0.1.0-next.28",
4
+ "description": "Live Playwright e2e progress from a run log, as a status line entry and a pane",
5
+ "types": "./types/index.d.ts"
6
+ }
@@ -0,0 +1 @@
1
+ { "modules": ["./register.tsx"] }
@@ -0,0 +1,442 @@
1
+ import { atom, read, update } from 'claude-code';
2
+ import type { EngineInterface, Register, Timer } from 'claude-code';
3
+
4
+ import type { Progress } from '../types';
5
+
6
+ const PANE = 'e2e-progress';
7
+ const HANDOFFS = ['.claude/handoffs', '.agents/handoffs'];
8
+ const WORKTREES = '.claude/worktrees';
9
+ const TICK_MS = 5000;
10
+ const STALE_MS = 15_000;
11
+ const HIDE_AFTER_MS = 60_000;
12
+ const MIN_ETA_TESTS = 5;
13
+ const MIN_ETA_MS = 30_000;
14
+ const MAX_CANDIDATES = 12;
15
+ const STALLED_MS = 15 * 60_000;
16
+ const runs = atom({ plugin: 'e2e-progress', key: 'runs' } as const, []);
17
+ const pinnedPath = atom({ plugin: 'e2e-progress', key: 'path' } as const, null);
18
+
19
+ const count = (text: string, re: RegExp) => (text.match(re) ?? []).length;
20
+ const summary = (text: string, word: string) => {
21
+ const hit = text.match(new RegExp(`^\\s+(\\d+) ${word}\\b`, 'm'));
22
+ return hit ? Number(hit[1]) : undefined;
23
+ };
24
+
25
+ const doneOf = (p: Progress) => p.passed + p.failed + p.skipped;
26
+
27
+ const FAIL_LINE = /✘\s+\d+\s+\[[^\]]+\]\s+›\s+(\S+?):(\d+):\d+\s+›\s+(.*)$/;
28
+ const TEST_DECLARATION = /\btest(\.\w+)?\(/;
29
+ const indentOf = (line: string) => line.length - line.trimStart().length;
30
+
31
+ const titleOf = (rest: string) =>
32
+ (
33
+ rest
34
+ .replace(/\s+@\S+.*$/, '')
35
+ .replace(/\s+\([\d.]+m?s\)$/, '')
36
+ .split(' › ')
37
+ .pop() ?? ''
38
+ ).trim();
39
+
40
+ const declaresAt = (source: string[], index: number, title: string) => {
41
+ if (!TEST_DECLARATION.test(source[index] ?? '')) return false;
42
+ const head = source.slice(index, index + 3).join('\n');
43
+ return head.includes(title.slice(0, 25)) || head.includes('`');
44
+ };
45
+
46
+ const callsTestFail = (source: string[], index: number) => {
47
+ const declaration = source[index] ?? '';
48
+ if (/\btest\.fail\(/.test(declaration)) return true;
49
+ const indent = indentOf(declaration);
50
+ for (const line of source.slice(index + 1)) {
51
+ if (line.trim() !== '' && indentOf(line) <= indent && !/=>\s*\{\s*$/.test(line)) return false;
52
+ if (/\btest\.fail\(/.test(line)) return true;
53
+ }
54
+ return false;
55
+ };
56
+
57
+ const sourceCache = new Map<string, string[] | null>();
58
+
59
+ async function sourceOf($: EngineInterface, path: string) {
60
+ if (!sourceCache.has(path))
61
+ sourceCache.set(
62
+ path,
63
+ await $.fs.read(path).then(
64
+ (t) => t.split('\n'),
65
+ () => null,
66
+ ),
67
+ );
68
+ return sourceCache.get(path) ?? null;
69
+ }
70
+
71
+ async function rootsFor($: EngineInterface, logPath: string) {
72
+ const at = Math.max(...HANDOFFS.map((dir) => logPath.indexOf(dir)));
73
+ const own = at > 0 ? logPath.slice(0, at) : '';
74
+ const worktrees = await $.fs.list(WORKTREES).catch(() => []);
75
+ const all = [own, '', ...worktrees.filter((d) => d.kind === 'dir').map((d) => `${WORKTREES}/${d.name}/`)];
76
+ return [...new Set(all)];
77
+ }
78
+
79
+ async function expectedFailures($: EngineInterface, logPath: string, text: string) {
80
+ const expected = new Set<string>();
81
+ const failLines = text.split('\n').filter((line) => FAIL_LINE.test(line));
82
+ if (failLines.length === 0) return expected;
83
+ const roots = await rootsFor($, logPath);
84
+ for (const line of failLines) {
85
+ const [, file = '', lineNo = '0', rest = ''] = line.match(FAIL_LINE) ?? [];
86
+ const index = Number(lineNo) - 1;
87
+ const title = titleOf(rest);
88
+ for (const root of roots) {
89
+ const source = await sourceOf($, `${root}${file}`);
90
+ if (source === null || !declaresAt(source, index, title)) continue;
91
+ if (callsTestFail(source, index)) expected.add(line);
92
+ break;
93
+ }
94
+ }
95
+ return expected;
96
+ }
97
+
98
+ const parse = (
99
+ path: string,
100
+ text: string,
101
+ expected: Set<string>,
102
+ mtimeMs: number,
103
+ nowMs: number,
104
+ last: Progress | null,
105
+ ): Progress => {
106
+ const isDone = /^\s+\d+ (passed|failed|did not run)\b/m.test(text) || /^EXIT \d+/m.test(text);
107
+ const lines = text.split('\n');
108
+ const listFailures = lines
109
+ .filter((line) => line.includes('✘') && !expected.has(line))
110
+ .map((line) => line.replace(/^.*?✘\s+\d+\s+\[[^\]]+\]\s+›\s+/, '').replace(/\s+@\S+.*$/, ''));
111
+ const lineFailures = new Map<string, string>();
112
+ for (const line of lines) {
113
+ const hit = line.match(/^\s+(\d+)\) \[[^\]]+\] › (.*)$/);
114
+ if (hit?.[1] && hit[2]) lineFailures.set(hit[1], hit[2].replace(/\s+@\S+.*$/, '').replace(/\s*─+\s*$/, ''));
115
+ }
116
+ const failures = isDone && lineFailures.size > 0 ? [...lineFailures.values()] : listFailures;
117
+ const started = Math.max(0, ...[...text.matchAll(/^\[(\d+)\/\d+\]/gm)].map((m) => Number(m[1])));
118
+ const ticks = count(text, /✓/g);
119
+ const p = {
120
+ path,
121
+ total: Number(text.match(/Running (\d+) tests?/)?.[1] ?? 0),
122
+ passed: summary(text, 'passed') ?? (ticks > 0 ? ticks + expected.size : Math.max(0, started - failures.length)),
123
+ failed: summary(text, 'failed') ?? failures.length,
124
+ skipped: summary(text, 'skipped') ?? (ticks > 0 ? count(text, /^\s+-\s+\d+\s/gm) : 0),
125
+ flaky: summary(text, 'flaky') ?? 0,
126
+ didNotRun: summary(text, 'did not run') ?? 0,
127
+ workers: Number(text.match(/Running \d+ tests? using (\d+) workers?/)?.[1] ?? 1),
128
+ duration: text.match(/^\s+\d+ passed \(([^)]+)\)/m)?.[1],
129
+ expected: expected.size,
130
+ isDone,
131
+ failures,
132
+ mtimeMs,
133
+ firstSeenMs: nowMs,
134
+ firstDone: 0,
135
+ };
136
+ const isSameRun =
137
+ last !== null &&
138
+ last.path === path &&
139
+ !last.isDone &&
140
+ Number.isFinite(last.firstSeenMs) &&
141
+ Number.isFinite(last.firstDone) &&
142
+ doneOf(p) >= last.firstDone;
143
+ return isSameRun
144
+ ? { ...p, firstSeenMs: last.firstSeenMs, firstDone: last.firstDone }
145
+ : { ...p, firstDone: doneOf(p) };
146
+ };
147
+
148
+ const FAILURES_FILE_LINE = /^\[failure-reporter\] Writing failures to (.+)$/m;
149
+
150
+ type FailureRecord = { test: string; location: string; error?: string; detail?: string | null };
151
+
152
+ const recordsOf = (text: string): FailureRecord[] =>
153
+ text.split('\n').flatMap((line) => {
154
+ if (line.trim() === '') return [];
155
+ try {
156
+ const record = JSON.parse(line);
157
+ return typeof record?.test === 'string' && typeof record?.location === 'string' ? [record] : [];
158
+ } catch {
159
+ return [];
160
+ }
161
+ });
162
+
163
+ const firstErrorLine = (error: string | undefined) =>
164
+ (error ?? '')
165
+ .split('\n')
166
+ .find((line) => line.trim() !== '')
167
+ ?.replace(/^Error:\s*/, '')
168
+ .trim() ?? '';
169
+
170
+ const failuresOf = (records: FailureRecord[]) => {
171
+ const latest = new Map<string, FailureRecord>();
172
+ for (const record of records) latest.set(`${record.location} › ${record.test}`, record);
173
+ return [...latest].map(([key, record]) => {
174
+ const error = firstErrorLine(record.error);
175
+ const line = error ? `${key} — ${error}` : key;
176
+ return record.detail ? `${line} (${record.detail})` : line;
177
+ });
178
+ };
179
+
180
+ async function withRecords($: EngineInterface, p: Progress, text: string): Promise<Progress> {
181
+ const file = text.match(FAILURES_FILE_LINE)?.[1]?.trim();
182
+ if (!file) return p;
183
+ const records = await $.fs.read(file).then(recordsOf, () => undefined);
184
+ if (records === undefined) return p;
185
+ const failures = failuresOf(records);
186
+ return { ...p, failures, failed: summary(text, 'failed') ?? failures.length };
187
+ }
188
+
189
+ const remainingMs = (p: Progress, nowMs: number) => {
190
+ const doneSince = doneOf(p) - p.firstDone;
191
+ const elapsed = nowMs - p.firstSeenMs;
192
+ if (p.isDone || doneSince < MIN_ETA_TESTS || elapsed < MIN_ETA_MS) return undefined;
193
+ return ((p.total - doneOf(p)) / doneSince) * elapsed;
194
+ };
195
+
196
+ const formatEta = (ms: number | undefined) => {
197
+ if (ms === undefined) return '';
198
+ const minutes = Math.round(ms / 60_000);
199
+ return minutes < 1 ? ' ~<1m left' : ` ~${minutes}m left`;
200
+ };
201
+
202
+ type LogFile = { path: string; mtimeMs: number };
203
+
204
+ async function logsUnder($: EngineInterface, dir: string, depth: number): Promise<LogFile[]> {
205
+ const entries = await $.fs.list(dir).catch(() => []);
206
+ const files = entries
207
+ .filter((f) => f.kind === 'file' && f.name.endsWith('.log'))
208
+ .map((f) => ({ path: `${dir}/${f.name}`, mtimeMs: f.mtimeMs }));
209
+ if (depth === 0) return files;
210
+ const nested = await Promise.all(
211
+ entries.filter((d) => d.kind === 'dir').map((d) => logsUnder($, `${dir}/${d.name}`, depth - 1)),
212
+ );
213
+ return [...files, ...nested.flat()];
214
+ }
215
+
216
+ async function baseDirs($: EngineInterface) {
217
+ const bases = new Set([await $.session.root()]);
218
+ let dir = await $.session.cwd();
219
+ for (let up = 0; up <= 3 && dir !== ''; up++) {
220
+ bases.add(dir);
221
+ dir = dir.slice(0, dir.lastIndexOf('/'));
222
+ }
223
+ return [...bases];
224
+ }
225
+
226
+ async function handoffDirs($: EngineInterface) {
227
+ const perBase = await Promise.all(
228
+ (await baseDirs($)).map(async (base) => {
229
+ const worktrees = await $.fs.list(`${base}/${WORKTREES}`).catch(() => []);
230
+ const roots = [base, ...worktrees.filter((d) => d.kind === 'dir').map((d) => `${base}/${WORKTREES}/${d.name}`)];
231
+ return roots.flatMap((root) => HANDOFFS.map((dir) => `${root}/${dir}`));
232
+ }),
233
+ );
234
+ return perBase.flat();
235
+ }
236
+
237
+ const nameOf = (path: string) => (path.split('/').pop() ?? path).replace(/\.log$/, '');
238
+
239
+ const isRunLog = (text: string) => /^Running \d+ tests?/m.test(text);
240
+
241
+ const notRunLogs = new Map<string, number>();
242
+
243
+ async function candidateLogs($: EngineInterface) {
244
+ const perDir = await Promise.all((await handoffDirs($)).map((dir) => logsUnder($, dir, 2)));
245
+ const unique = new Map(perDir.flat().map((log) => [log.path, log]));
246
+ return [...unique.values()].sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, MAX_CANDIDATES);
247
+ }
248
+
249
+ async function newestLog($: EngineInterface) {
250
+ for (const log of await candidateLogs($)) {
251
+ if (isRunLog(await $.fs.read(log.path).catch(() => ''))) return log;
252
+ }
253
+ return undefined;
254
+ }
255
+
256
+ async function pinnedLog($: EngineInterface): Promise<LogFile | undefined> {
257
+ const pinned = await read($, pinnedPath);
258
+ if (pinned === null) return undefined;
259
+ const stat = await $.fs.stat(pinned).catch(() => undefined);
260
+ const newest = await newestLog($);
261
+ if (newest !== undefined && newest.path !== pinned && (stat === undefined || newest.mtimeMs > stat.mtimeMs)) {
262
+ await update($, pinnedPath, () => null);
263
+ return undefined;
264
+ }
265
+ return stat === undefined ? undefined : { path: pinned, mtimeMs: stat.mtimeMs };
266
+ }
267
+
268
+ const isVisible = (p: Progress, nowMs: number) =>
269
+ p.isDone ? nowMs - p.mtimeMs <= HIDE_AFTER_MS : nowMs - p.mtimeMs <= STALLED_MS;
270
+
271
+ let isHidden = false;
272
+ let timer: Timer | undefined;
273
+ let lastTickMs = 0;
274
+ let shownStatus: string | undefined | null = null;
275
+
276
+ function setStatus($: EngineInterface, text: string | undefined) {
277
+ if (text === shownStatus) return;
278
+ shownStatus = text;
279
+ $.ui.status(text);
280
+ }
281
+
282
+ const statusOf = (p: Progress, nowMs: number) =>
283
+ `${nameOf(p.path)} w${p.workers} ${p.isDone ? `done ${p.duration ?? ''}`.trim() : `${doneOf(p)}/${p.total}`} ✓${p.passed} ✘${p.failed}` +
284
+ `${p.flaky ? ` ~${p.flaky}` : ''}${p.skipped ? ` -${p.skipped}` : ''}${p.didNotRun ? ` ⊘${p.didNotRun}` : ''}${formatEta(remainingMs(p, nowMs))}`;
285
+
286
+ async function tick($: EngineInterface) {
287
+ const nowMs = await $.clock.now();
288
+ const pinned = await pinnedLog($);
289
+ const logs = pinned ? [pinned] : await candidateLogs($);
290
+ const last = new Map((await read($, runs)).map((p) => [p.path, p]));
291
+ const next: Progress[] = [];
292
+ for (const log of logs) {
293
+ const previous = last.get(log.path);
294
+ if (previous?.mtimeMs === log.mtimeMs) {
295
+ next.push(previous);
296
+ continue;
297
+ }
298
+ if (notRunLogs.get(log.path) === log.mtimeMs) continue;
299
+ const text = await $.fs.read(log.path).catch(() => '');
300
+ if (!isRunLog(text)) {
301
+ notRunLogs.set(log.path, log.mtimeMs);
302
+ continue;
303
+ }
304
+ sourceCache.clear();
305
+ const parsed = parse(
306
+ log.path,
307
+ text,
308
+ await expectedFailures($, log.path, text),
309
+ log.mtimeMs,
310
+ nowMs,
311
+ previous ?? null,
312
+ );
313
+ next.push(await withRecords($, parsed, text));
314
+ }
315
+ const visible = next.filter((p) => isVisible(p, nowMs)).sort((a, b) => a.path.localeCompare(b.path));
316
+ await update($, runs, () => visible);
317
+
318
+ if (visible.length === 0) {
319
+ setStatus($, undefined);
320
+ if (!isHidden) {
321
+ isHidden = true;
322
+ await $.ui.close({ id: PANE }).catch(() => undefined);
323
+ }
324
+ return;
325
+ }
326
+
327
+ isHidden = false;
328
+ setStatus($, `e2e ${visible.map((p) => statusOf(p, nowMs)).join(' │ ')}`);
329
+ }
330
+
331
+ async function run($: EngineInterface) {
332
+ lastTickMs = await $.clock.now();
333
+ try {
334
+ await tick($);
335
+ } catch (error) {
336
+ setStatus($, `e2e-progress failed: ${String(error)}`);
337
+ }
338
+ }
339
+
340
+ function start($: EngineInterface) {
341
+ timer?.cancel();
342
+ timer = $.clock.every(TICK_MS, () => run($));
343
+ }
344
+
345
+ async function revive($: EngineInterface) {
346
+ if ((await $.clock.now()) - lastTickMs < STALE_MS) return;
347
+ start($);
348
+ await run($);
349
+ }
350
+
351
+ export const register: Register = (on) => {
352
+ on('session.start', async ($, e, next) => {
353
+ await $.command.register({
354
+ name: 'e2e',
355
+ description:
356
+ 'Show e2e run progress. Optional arg: a log path to pin; "auto" removes the pin; "off" clears the status',
357
+ });
358
+ start($);
359
+ return next(e);
360
+ });
361
+
362
+ on('prompt.submit', async ($, e, next) => {
363
+ await revive($);
364
+ return next(e);
365
+ });
366
+
367
+ on('tool.call', async ($, e, next) => {
368
+ await revive($);
369
+ return next(e);
370
+ });
371
+
372
+ on('turn.complete', async ($, e, next) => {
373
+ await revive($);
374
+ return next(e);
375
+ });
376
+
377
+ on('command.run', { command: 'e2e' }, async ($, e) => {
378
+ const arg = e.args.trim();
379
+ if (arg === 'off') {
380
+ setStatus($, undefined);
381
+ return { text: 'e2e status cleared until the log changes.' };
382
+ }
383
+ if (arg === 'auto') {
384
+ await update($, pinnedPath, () => null);
385
+ start($);
386
+ await run($);
387
+ return { text: `Pin removed. Watching the newest logs under ${HANDOFFS.join(' and ')}.` };
388
+ }
389
+ if (arg !== '') await update($, pinnedPath, () => arg);
390
+ start($);
391
+ await $.ui.open({ id: PANE, title: 'E2E progress' });
392
+ return { text: arg ? `Watching ${arg}.` : 'E2E progress pane opened.' };
393
+ });
394
+
395
+ on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
396
+ const { Box, Text } = $.ui.resolve(e);
397
+ const shown = await read($, runs);
398
+ if (shown.length === 0)
399
+ return (
400
+ <Text dimColor>
401
+ No active run log under {HANDOFFS.join(' or ')}, also in {WORKTREES}/*.
402
+ </Text>
403
+ );
404
+
405
+ const nowMs = await $.clock.now();
406
+ const room = Math.max(1, Math.floor(((e.viewport?.rows ?? 24) - 6 * shown.length) / shown.length));
407
+ return (
408
+ <Box flexDirection="column">
409
+ {shown.map((p) => {
410
+ const done = doneOf(p);
411
+ return (
412
+ <Box flexDirection="column" marginBottom={1}>
413
+ <Text bold>
414
+ {nameOf(p.path)} <Text dimColor>· {p.workers} workers</Text>
415
+ </Text>
416
+ <Text dimColor wrap="truncate-start">
417
+ {p.path}
418
+ </Text>
419
+ <Text>
420
+ {p.isDone ? `finished in ${p.duration ?? '?'}` : `${done} / ${p.total}`}
421
+ {p.total > 0 && !p.isDone ? ` (${Math.round((done / p.total) * 100)}%)` : ''}
422
+ {formatEta(remainingMs(p, nowMs))}
423
+ </Text>
424
+ <Text>
425
+ <Text color="green">✓ {p.passed}</Text> <Text color="red">✘ {p.failed}</Text>{' '}
426
+ <Text dimColor>- {p.skipped} skipped</Text>
427
+ {p.didNotRun > 0 ? <Text color="yellow"> ⊘ {p.didNotRun} did not run</Text> : ''}
428
+ {p.flaky > 0 ? <Text color="yellow"> ~ {p.flaky} flaky</Text> : ''}
429
+ </Text>
430
+ {p.expected > 0 && <Text dimColor>{p.expected} expected failures (test.fail) counted as passed</Text>}
431
+ {p.failures.slice(-room).map((f) => (
432
+ <Text color="red" wrap="truncate-end">
433
+ ✘ {f}
434
+ </Text>
435
+ ))}
436
+ </Box>
437
+ );
438
+ })}
439
+ </Box>
440
+ );
441
+ });
442
+ };
@@ -0,0 +1,3 @@
1
+ {
2
+ "extends": "./.claude-plugin/types/tsconfig.json"
3
+ }
@@ -0,0 +1,23 @@
1
+ export type Progress = {
2
+ path: string;
3
+ total: number;
4
+ passed: number;
5
+ failed: number;
6
+ skipped: number;
7
+ flaky: number;
8
+ didNotRun: number;
9
+ workers: number;
10
+ duration: string | undefined;
11
+ expected: number;
12
+ isDone: boolean;
13
+ failures: string[];
14
+ mtimeMs: number;
15
+ firstSeenMs: number;
16
+ firstDone: number;
17
+ };
18
+
19
+ declare module 'claude-code' {
20
+ interface PluginState {
21
+ 'e2e-progress': { runs: Progress[]; path: string | null };
22
+ }
23
+ }
@@ -0,0 +1,11 @@
1
+ ---
2
+ name: agent-sessions-wake-up
3
+ description: A prompt that says "New mailbox message for <name>" means run the inbox command in it and act on the result; read the agent-sessions skill before you edit a file another agent may edit.
4
+ kind: rule
5
+ scope: both
6
+ ---
7
+
8
+ ## Agent sessions
9
+
10
+ If a prompt says `New mailbox message for <name>`, run the `inbox` command in that prompt and act on
11
+ the messages. Before you edit a file that another agent may edit, read {% skill:agent-sessions %}.
@@ -0,0 +1,81 @@
1
+ ---
2
+ name: agent-sessions
3
+ description: Run agents in tmux sessions of one checkout, message them, wake idle ones, restart a session with a prompt, and claim paths so two agents do not overwrite each other. Read before you edit a shared file while another agent may be active, and when the user says "mailbox", "coordinate with the other agent", "claim a file", "hand off to the other agent", or "restart the session".
4
+ kind: skill
5
+ scope: both
6
+ ---
7
+
8
+ # Agent sessions
9
+
10
+ One script, {%resource:agent-sessions.mjs%}, does three jobs for the agents of one checkout:
11
+
12
+ - **Sessions.** It runs each agent in a `tmux` session, and it can clear a session and start it with a prompt.
13
+ - **Messages.** Agents send each other messages through an append-only JSON-lines file, the mailbox. A dispatcher wakes an idle agent.
14
+ - **Claims.** An agent announces the paths it edits, so two agents do not overwrite each other.
15
+
16
+ Below, `<skill-dir>` is the directory this file sits in, and `<name>` is your own sender name. Where
17
+ the `ethlete-agents` package is a dependency, `npx ethlete-agents sessions <command>` runs the same
18
+ script, so you do not need `<skill-dir>`. The older name `npx ethlete-agents mailbox` works too.
19
+
20
+ ```bash
21
+ node <skill-dir>/agent-sessions.mjs run <name> <command...>
22
+ node <skill-dir>/agent-sessions.mjs up <name>[=<command>]...
23
+ node <skill-dir>/agent-sessions.mjs restart <name> [--after <seconds>] <prompt...>
24
+ node <skill-dir>/agent-sessions.mjs down [<name>...]
25
+ node <skill-dir>/agent-sessions.mjs shell-install [<agent>...] [--file <path>] [--remove] [--dry-run]
26
+ node <skill-dir>/agent-sessions.mjs shell-init [<agent>...]
27
+ node <skill-dir>/agent-sessions.mjs send <name> [--to <agent>] '<message>'
28
+ node <skill-dir>/agent-sessions.mjs inbox <name> [--wait <seconds>]
29
+ node <skill-dir>/agent-sessions.mjs read [--last <n>] [--all]
30
+ node <skill-dir>/agent-sessions.mjs watch <name>
31
+ node <skill-dir>/agent-sessions.mjs dispatch
32
+ node <skill-dir>/agent-sessions.mjs claim <name> <path...> [--ttl <hours>] [--force]
33
+ node <skill-dir>/agent-sessions.mjs release <name> [path...]
34
+ node <skill-dir>/agent-sessions.mjs claims
35
+ ```
36
+
37
+ ## Quick start for a person
38
+
39
+ Nothing runs until you start it, so a person who does not use this does nothing. To try it:
40
+
41
+ 1. Run `node <skill-dir>/agent-sessions.mjs shell-install`. It adds one function per agent to your shell file.
42
+ 2. Open a new terminal. In the repo, type `claude` or `codex` as usual. The function starts the agent in a `tmux` session and starts the dispatcher.
43
+ 3. Ask one agent to message the other. An idle agent wakes when a message carries `--to <agent>`.
44
+ 4. Run `node <skill-dir>/agent-sessions.mjs down` to stop the dispatcher. Run `shell-install --remove` to undo step 1.
45
+
46
+ ## Sessions
47
+
48
+ `run <name> <command...>` starts the command in the `tmux` session `<name>@<repo>`, or attaches if it runs already. It also starts the dispatcher when none runs. Session names carry the repo name, so `codex` in two repos never collides. If that session has a terminal attached already, `run` starts `<name>-2`, then `<name>-3`, and so on. A detached session is attached again. So a second `claude` terminal gets its own agent.
49
+
50
+ `up codex=codex gemini=gemini` starts a detached `tmux` session per agent, named like its sender name, and runs the command in it. It also starts a session named `mailbox-dispatch` that runs `dispatch`. A running session is left alone. Attach to a session with `tmux attach -t <name>`. Detach with `Ctrl-b`, then `d`. The session and its agent keep running. Use `down` to stop the dispatcher and the named sessions.
51
+
52
+ `restart <name> <prompt...>` types the clear command (`/clear`, or `/new` for a `codex` name) and then the prompt into the session `<name>@<repo>` after `--after` seconds (default 5). The command returns at once, so end your turn after it. An agent uses it to clear itself and resume from its saved work state. It refuses any session other than the caller's own (from `$TMUX_PANE`), and the error names the caller's own sender name. Background commands of the old session still report into the new one.
53
+
54
+ `shell-install` writes one shell function per agent into `~/.zshrc` or `~/.bashrc`, between two marker lines. A second run replaces the block, and `--remove` deletes it. Use `--file` for another startup file and `--dry-run` to preview. After a new terminal opens, typing `claude` or `codex` in a repo with this skill runs the agent through `run`. Elsewhere the function starts the plain command. Run `shell-install` only when the user asks: it changes a file the user owns. `shell-init` prints the same functions without writing a file.
55
+
56
+ Choose a `<name>` that no other agent uses, for example `agent-a` or `agent-b`. Two sessions with one name look like one agent.
57
+
58
+ ## Messages
59
+
60
+ The mailbox file is `.ethlete/mailbox.jsonl` in the git root. The first write also creates `.ethlete/.gitignore`, so the file stays out of git. Set `AGENT_MAILBOX` to use another path. For two Claude Code sessions, the `SendMessage` tool is the better channel. The mailbox is for agents that cannot reach each other directly.
61
+
62
+ An idle agent cannot read the mailbox. One `dispatch` process outside every agent fixes this. It polls the mailbox for free. When a message carries `--to <agent>`, it waits a short grace period and then, if the agent has not read the message, types a wake-up line into the `tmux` session `<agent>@<repo>`, or a session named only `<agent>`. The agent then runs `inbox` and acts. A message without `--to` wakes nobody.
63
+
64
+ If the prompt of the agent holds typed text, the dispatcher waits until that text is sent or cleared. It checks again every 2 seconds. An open menu, for example a permission dialog, also makes it wait. If the agent is in the middle of a turn, the wake-up line waits as its next prompt. An agent in a `tmux` session does not need the `--wait` loop, which costs a turn at every timeout. If you wait for an answer in a session without `tmux`, run `inbox <name> --wait 120`: it blocks until a message arrives or 120 seconds pass. If it prints "No new messages.", run it again. A background `--wait` ends after one message, so it is not a lasting watch.
65
+
66
+ - `inbox` keeps one cursor per sender name, so it never repeats a message. The first call shows the last 20.
67
+ - `read` shows the last 20 messages. Use `--all` for the full history.
68
+ - `send` with no message reads it from standard input, so a long message needs no shell quoting.
69
+ - `watch` is optional. It runs until you stop it. In Claude Code, start it with the `Monitor` tool, so a new message wakes the session. Without such a feature, rely on `inbox`.
70
+ - Never rewrite or truncate the file. Only the script appends to it.
71
+ - Keep credentials and personal data out of the mailbox.
72
+
73
+ ## Claims
74
+
75
+ 1. At the start of a task, run `claims` and `inbox`. They show what other agents hold and said.
76
+ 2. Before you edit a file, run `claim` with the exact paths. A directory path claims everything below it. `claim` also prints the messages you have not seen yet.
77
+ 3. If `claim` reports a conflict, do not edit. Run `send` to ask the holder for a handoff, then wait.
78
+ 4. After each larger step, run `inbox` again. Most agents cannot wake on new output, so you must pull messages yourself.
79
+ 5. When you finish, run `release`. A claim also expires after 4 hours, or after `--ttl` hours.
80
+
81
+ A claim is advisory, not a lock. `--force` claims over another agent: use it only after the holder agreed. Silence is not approval. Acknowledge each handoff in words. No agent starts automatically. The user starts each one.