@ethlete/agent-rules 0.1.0-next.26 → 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.
- package/AGENTS.md +28 -0
- package/CHANGELOG.md +114 -0
- package/CLAUDE.md +1 -0
- package/THIRD-PARTY-LICENSES.md +1 -1
- package/content/hooks/context-warning.py +46 -7
- package/content/mods/.claude-plugin/marketplace.json +11 -0
- package/content/mods/plugins/e2e-progress/.claude-plugin/plugin.json +6 -0
- package/content/mods/plugins/e2e-progress/hooks/hooks.json +1 -0
- package/content/mods/plugins/e2e-progress/hooks/register.tsx +442 -0
- package/content/mods/plugins/e2e-progress/tsconfig.json +3 -0
- package/content/mods/plugins/e2e-progress/types/index.d.ts +23 -0
- package/content/rules/agent-sessions-wake-up.md +11 -0
- package/content/rules/lint-and-format.md +2 -1
- package/content/rules/subagent-models.md +4 -0
- package/content/skills/agent-sessions/SKILL.md +81 -0
- package/content/skills/agent-sessions/agent-sessions.mjs +436 -0
- package/content/skills/design-exploration/SKILL.md +429 -135
- package/content/skills/e2e-triage/SKILL.md +80 -0
- package/content/skills/git-commit/SKILL.md +5 -0
- package/content/skills/grilling/SKILL.md +17 -0
- package/content/skills/handoff/SKILL.md +51 -1
- package/content/skills/sdk-update/SKILL.md +7 -0
- package/package.json +1 -1
- package/src/index.js +7 -0
- package/src/index.js.map +1 -1
- package/src/lib/config.d.ts +2 -0
- package/src/lib/config.js +5 -3
- package/src/lib/config.js.map +1 -1
- package/src/lib/plan.js +4 -0
- package/src/lib/plan.js.map +1 -1
- package/src/lib/sessions-command.d.ts +4 -0
- package/src/lib/sessions-command.js +16 -0
- package/src/lib/sessions-command.js.map +1 -0
- package/src/lib/targets/claude-hooks.d.ts +2 -0
- package/src/lib/targets/claude-hooks.js +4 -1
- package/src/lib/targets/claude-hooks.js.map +1 -1
- package/src/lib/targets/claude-mods.d.ts +20 -0
- package/src/lib/targets/claude-mods.js +47 -0
- package/src/lib/targets/claude-mods.js.map +1 -0
- package/src/lib/targets/hooks-shared.d.ts +1 -0
- package/src/lib/targets/hooks-shared.js +3 -2
- package/src/lib/targets/hooks-shared.js.map +1 -1
|
@@ -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,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 %}.
|
|
@@ -19,7 +19,8 @@ so let them do the work before correcting anything by hand:
|
|
|
19
19
|
For judgment calls lint cannot enforce, load the repository's focused guidance for the
|
|
20
20
|
code you are changing.
|
|
21
21
|
|
|
22
|
-
Format every edited Prettier-supported source file before wrapping up
|
|
22
|
+
Format every edited Prettier-supported source file before wrapping up, in one call over all of
|
|
23
|
+
them, not one call per file. Do not send binary,
|
|
23
24
|
generated, or unsupported files to this command:
|
|
24
25
|
|
|
25
26
|
```bash
|
|
@@ -23,6 +23,10 @@ means, and say "keep it brief" for a lookup. Two calls need no `model` of their
|
|
|
23
23
|
agent type carries the model and reasoning effort its own definition sets, and a fork always
|
|
24
24
|
inherits the parent's model.
|
|
25
25
|
|
|
26
|
+
A subagent starts cold: it loads the full start context (about 50k tokens) before its first step,
|
|
27
|
+
and measured subagents cost as much as the sessions that spawn them. Do a lookup of a few tool
|
|
28
|
+
calls yourself. Give each subagent one large slice, not several small ones.
|
|
29
|
+
|
|
26
30
|
A subagent has the same context budget as the session, measured on its own transcript, and the
|
|
27
31
|
context-warning hook tells it to hand back at the critical tier. Size each prompt to fit: one
|
|
28
32
|
slice of work, the files it needs named, and "keep your context small - no whole big files".
|
|
@@ -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.
|