@ethlete/agent-rules 0.1.0-next.27 → 0.1.0-next.29
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/CHANGELOG.md +31 -0
- package/content/hooks/context-warning.py +32 -3
- 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/skills/agent-sessions/SKILL.md +81 -0
- package/content/skills/agent-sessions/agent-sessions.mjs +460 -0
- package/content/skills/design-exploration/SKILL.md +11 -2
- package/content/skills/e2e-triage/SKILL.md +80 -0
- package/content/skills/handoff/SKILL.md +48 -2
- 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
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
# @ethlete/agent-rules
|
|
2
2
|
|
|
3
|
+
## 0.1.0-next.29
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- `agent-sessions restart` now waits until the session ended its turn and its prompt is empty, so it no longer types into a running turn or into your draft.
|
|
8
|
+
- A multi-target sketch merge gives each target only the `shared/` files it imports, directly or transitively; an unimported file returns to the target that held it. `sketch new` refuses targets whose `shared/` files clash.
|
|
9
|
+
- `et design sketch new` takes several parts: one sketch, one variant folder holding them all, one `ready`, one merge; `ready` and `preview` name every part a `shared/` edit reaches; Studio shows every target part.
|
|
10
|
+
|
|
11
|
+
## 0.1.0-next.28
|
|
12
|
+
|
|
13
|
+
### Minor Changes
|
|
14
|
+
|
|
15
|
+
- 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.
|
|
16
|
+
- 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.
|
|
17
|
+
- 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.
|
|
18
|
+
|
|
19
|
+
### Patch Changes
|
|
20
|
+
|
|
21
|
+
- `agent-sessions restart` refuses a session the caller does not run in, and the handoff skill says how to read the own session name.
|
|
22
|
+
- 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.
|
|
23
|
+
- `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.
|
|
24
|
+
- The e2e-triage skill treats connection-refused groups on a local mock as infrastructure, and the e2e-progress mod shows the failure `detail`.
|
|
25
|
+
- 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.
|
|
26
|
+
- A handoff self-restart now resumes with the installed skill name and keeps both the restart reason and the text after the slug.
|
|
27
|
+
- 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`.
|
|
28
|
+
- The `sdk-update` skill warns that after Angular 22, `a?.b` gives `undefined`, not `null`, which breaks pipes that treat the two differently.
|
|
29
|
+
- 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.
|
|
30
|
+
- `et design shot --variants` takes `--round <key>` and `--variant <name>` to draw only the current round next to the merged row.
|
|
31
|
+
- 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.
|
|
32
|
+
- 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.
|
|
33
|
+
|
|
3
34
|
## 0.1.0-next.27
|
|
4
35
|
|
|
5
36
|
### 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
|
-
|
|
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
|
|
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 @@
|
|
|
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,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 %}.
|