@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.
- package/CHANGELOG.md +23 -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 +436 -0
- package/content/skills/design-exploration/SKILL.md +3 -1
- 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
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
import { execFileSync, spawn, spawnSync } from 'node:child_process';
|
|
2
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, watchFile, writeFileSync } from 'node:fs';
|
|
3
|
+
import { basename, dirname, join, relative, resolve } from 'node:path';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
const root = (() => {
|
|
8
|
+
try {
|
|
9
|
+
return execFileSync('git', ['rev-parse', '--show-toplevel'], {
|
|
10
|
+
encoding: 'utf8',
|
|
11
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
12
|
+
}).trim();
|
|
13
|
+
} catch {
|
|
14
|
+
return process.cwd();
|
|
15
|
+
}
|
|
16
|
+
})();
|
|
17
|
+
const mailbox = process.env.AGENT_MAILBOX ?? join(root, '.ethlete/mailbox.jsonl');
|
|
18
|
+
const script = fileURLToPath(import.meta.url);
|
|
19
|
+
const hasRootScript = (name) => {
|
|
20
|
+
try {
|
|
21
|
+
return Boolean(JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).scripts?.[name]);
|
|
22
|
+
} catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
const inboxCommand = (agent) => {
|
|
27
|
+
if (existsSync(join(root, 'node_modules/.bin/ethlete-agents'))) return `npx ethlete-agents sessions inbox ${agent}`;
|
|
28
|
+
if (hasRootScript('agents:sessions')) return `yarn agents:sessions inbox ${agent}`;
|
|
29
|
+
return `node ${script} inbox ${agent}`;
|
|
30
|
+
};
|
|
31
|
+
const DEFAULT_TTL_HOURS = 4;
|
|
32
|
+
const DEFAULT_LAST = 20;
|
|
33
|
+
const ENTER_DELAY_MS = 400;
|
|
34
|
+
const WAKE_GRACE_MS = 1500;
|
|
35
|
+
const DRAFT_RETRY_MS = 2000;
|
|
36
|
+
const RESTART_AFTER_SECONDS = 5;
|
|
37
|
+
const RESTART_CLEAR_MS = 2500;
|
|
38
|
+
const repoSlug = basename(root).replace(/[.:]/g, '-');
|
|
39
|
+
const sessionFor = (agent) => `${agent}@${repoSlug}`;
|
|
40
|
+
const DISPATCH_SESSION = process.env.AGENT_MAILBOX_DISPATCH_SESSION ?? `mailbox-dispatch@${repoSlug}`;
|
|
41
|
+
|
|
42
|
+
const takeFlag = (args, name, hasValue) => {
|
|
43
|
+
const index = args.indexOf(name);
|
|
44
|
+
if (index === -1) return undefined;
|
|
45
|
+
const [flag, value] = args.splice(index, hasValue ? 2 : 1);
|
|
46
|
+
return hasValue ? value : flag;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const args = process.argv.slice(2);
|
|
50
|
+
const last = takeFlag(args, '--last', true);
|
|
51
|
+
const ttl = takeFlag(args, '--ttl', true);
|
|
52
|
+
const all = takeFlag(args, '--all', false);
|
|
53
|
+
const force = takeFlag(args, '--force', false);
|
|
54
|
+
const waitSeconds = takeFlag(args, '--wait', true);
|
|
55
|
+
const recipient = takeFlag(args, '--to', true);
|
|
56
|
+
const rcFile = takeFlag(args, '--file', true);
|
|
57
|
+
const remove = takeFlag(args, '--remove', false);
|
|
58
|
+
const afterSeconds = takeFlag(args, '--after', true);
|
|
59
|
+
const now = takeFlag(args, '--now', false);
|
|
60
|
+
const dryRun = takeFlag(args, '--dry-run', false);
|
|
61
|
+
const [command, sender, ...words] = args;
|
|
62
|
+
|
|
63
|
+
const help = `Shared agent mailbox: ${mailbox}
|
|
64
|
+
|
|
65
|
+
Send: node ${script} send <name> [--to <agent>] '<message>' (no message: read it from stdin)
|
|
66
|
+
Inbox: node ${script} inbox <name> [--wait <seconds>] (only the messages you have not seen yet;
|
|
67
|
+
--wait blocks until one arrives or the time ends)
|
|
68
|
+
Read: node ${script} read [--last <n>] [--all] (default: the last ${DEFAULT_LAST} messages)
|
|
69
|
+
Watch: node ${script} watch <name>
|
|
70
|
+
Claim: node ${script} claim <name> <path...> [--ttl <hours>] [--force]
|
|
71
|
+
Release: node ${script} release <name> [path...] (no path: release every claim of <name>)
|
|
72
|
+
Claims: node ${script} claims
|
|
73
|
+
Up: node ${script} up <name>[=<command>]... (start a tmux session per agent, plus one that runs dispatch)
|
|
74
|
+
Run: node ${script} run <name> <command...> (start or attach the agent in its tmux session; starts dispatch)
|
|
75
|
+
Install: node ${script} shell-install [<agent>...] [--file <path>] [--remove] [--dry-run]
|
|
76
|
+
(write the shell functions into ~/.zshrc or ~/.bashrc)
|
|
77
|
+
Shell: node ${script} shell-init [<agent>...] (print shell functions that wrap each agent with run; default: claude codex)
|
|
78
|
+
Restart: node ${script} restart <name> [--after <seconds>] <prompt...>
|
|
79
|
+
(type the clear command and then the prompt into the tmux session of <name>)
|
|
80
|
+
Down: node ${script} down [<name>...] (stop dispatch; stop the named sessions too)
|
|
81
|
+
Dispatch: node ${script} dispatch (wake the tmux session named <agent> for each message sent --to <agent>)
|
|
82
|
+
|
|
83
|
+
Protocol: append one JSON object per line; never rewrite or truncate.
|
|
84
|
+
Fields: timestamp (UTC), sender, message. A claim also has paths and expires.
|
|
85
|
+
Pick a sender name no other agent uses. Claim the paths you edit before you edit them.
|
|
86
|
+
A claim is advisory, not a lock: it expires after ${DEFAULT_TTL_HOURS} hours unless --ttl says otherwise.
|
|
87
|
+
Silence is not approval. Acknowledge handoffs explicitly.
|
|
88
|
+
Watch prints existing messages and then new messages from other senders every second.
|
|
89
|
+
Keep credentials and personal data out of the mailbox. No agent is started automatically.
|
|
90
|
+
`;
|
|
91
|
+
|
|
92
|
+
const tmux = (...tmuxArgs) => spawnSync('tmux', tmuxArgs, { encoding: 'utf8' });
|
|
93
|
+
const quote = (text) => `'${text.replaceAll("'", "'\\''")}'`;
|
|
94
|
+
const sessionExists = (name) => tmux('has-session', '-t', `=${name}`).status === 0;
|
|
95
|
+
const ONE_SHOT_FLAGS = new Set(['-p', '--print', '-v', '--version', '-h', '--help']);
|
|
96
|
+
const runsInSession = (agentArgs) =>
|
|
97
|
+
Boolean(process.stdin.isTTY && process.stdout.isTTY) && !agentArgs.some((arg) => ONE_SHOT_FLAGS.has(arg));
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Reads the last prompt line (`❯` for Claude Code, `›` for Codex) of the pane. Dim text is the empty
|
|
101
|
+
* prompt's placeholder, so it does not count. A menu cursor (`❯ 1. Yes`) also counts, which keeps a
|
|
102
|
+
* wake-up line from answering a permission dialog.
|
|
103
|
+
*/
|
|
104
|
+
const SGR = `${String.fromCharCode(27)}\\[([0-9;]*)m`;
|
|
105
|
+
const hasDraft = (session) => {
|
|
106
|
+
const pane = tmux('capture-pane', '-p', '-e', '-t', `=${session}:`).stdout ?? '';
|
|
107
|
+
const prompt = pane
|
|
108
|
+
.split('\n')
|
|
109
|
+
.reverse()
|
|
110
|
+
.find((line) => /^\s*[❯›]/.test(line.replace(new RegExp(SGR, 'g'), '')));
|
|
111
|
+
if (!prompt) return false;
|
|
112
|
+
let dim = false;
|
|
113
|
+
const visible = prompt
|
|
114
|
+
.split(new RegExp(`(${SGR.replace('(', '(?:')})`))
|
|
115
|
+
.map((part) => {
|
|
116
|
+
const codes = part.match(new RegExp(`^${SGR}$`))?.[1].split(';');
|
|
117
|
+
if (!codes) return dim ? '' : part;
|
|
118
|
+
codes.forEach((code) => {
|
|
119
|
+
if (code === '2') dim = true;
|
|
120
|
+
if (code === '' || code === '0' || code === '22') dim = false;
|
|
121
|
+
});
|
|
122
|
+
return '';
|
|
123
|
+
})
|
|
124
|
+
.join('');
|
|
125
|
+
return visible.replace(/^\s*[❯›]/, '').trim().length > 0;
|
|
126
|
+
};
|
|
127
|
+
const isAttached = (name) => tmux('list-clients', '-t', `=${name}`).stdout.trim() !== '';
|
|
128
|
+
const freeSessionFor = (agent) => {
|
|
129
|
+
for (let number = 1; ; number++) {
|
|
130
|
+
const session = sessionFor(number === 1 ? agent : `${agent}-${number}`);
|
|
131
|
+
if (!sessionExists(session) || !isAttached(session)) return session;
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
const startSession = (name, shellCommand, quiet = false) => {
|
|
135
|
+
if (sessionExists(name)) return quiet ? undefined : process.stdout.write(`Session ${name} already runs.\n`);
|
|
136
|
+
const result = tmux('new-session', '-d', '-s', name, '-c', root, ...(shellCommand ? [shellCommand] : []));
|
|
137
|
+
if (result.status !== 0) return process.stderr.write(`Could not start ${name}: ${result.stderr.trim()}\n`);
|
|
138
|
+
if (!quiet) process.stdout.write(`Started ${name}. Attach with: tmux attach -t ${name}\n`);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const IGNORED = ['mailbox.jsonl', 'mailbox.cursor.*'];
|
|
142
|
+
|
|
143
|
+
const prepare = () => {
|
|
144
|
+
mkdirSync(dirname(mailbox), { recursive: true });
|
|
145
|
+
if (process.env.AGENT_MAILBOX) return;
|
|
146
|
+
const ignore = join(dirname(mailbox), '.gitignore');
|
|
147
|
+
const present = existsSync(ignore) ? readFileSync(ignore, 'utf8') : '';
|
|
148
|
+
const missing = IGNORED.filter((line) => !present.split('\n').includes(line));
|
|
149
|
+
if (missing.length)
|
|
150
|
+
writeFileSync(ignore, `${present && !present.endsWith('\n') ? `${present}\n` : present}${missing.join('\n')}\n`);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const append = (entry) => {
|
|
154
|
+
prepare();
|
|
155
|
+
appendFileSync(mailbox, `${JSON.stringify({ timestamp: new Date().toISOString(), ...entry })}\n`, 'utf8');
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const parse = (line) => {
|
|
159
|
+
if (!line.trim()) return undefined;
|
|
160
|
+
try {
|
|
161
|
+
return JSON.parse(line);
|
|
162
|
+
} catch {
|
|
163
|
+
process.stderr.write('Ignoring malformed mailbox line.\n');
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const entries = () => (existsSync(mailbox) ? readFileSync(mailbox, 'utf8').split('\n').map(parse).filter(Boolean) : []);
|
|
169
|
+
|
|
170
|
+
const show = (entry) => process.stdout.write(`[${entry.timestamp}] ${entry.sender}: ${entry.message}\n`);
|
|
171
|
+
|
|
172
|
+
const inboxCursor = (reader) => join(dirname(mailbox), `mailbox.cursor.${reader}`);
|
|
173
|
+
|
|
174
|
+
const unreadFor = (reader) => {
|
|
175
|
+
const cursor = inboxCursor(reader);
|
|
176
|
+
const history = entries();
|
|
177
|
+
const seen = existsSync(cursor) ? Number(readFileSync(cursor, 'utf8')) : history.length - DEFAULT_LAST;
|
|
178
|
+
return { count: history.length, unread: history.slice(Math.max(0, seen)).filter((entry) => entry.sender !== reader) };
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
182
|
+
|
|
183
|
+
const showInbox = (reader, waitFor = 0) => {
|
|
184
|
+
const deadline = Date.now() + waitFor * 1000;
|
|
185
|
+
let { count, unread } = unreadFor(reader);
|
|
186
|
+
while (!unread.length && Date.now() < deadline) {
|
|
187
|
+
sleep(1000);
|
|
188
|
+
({ count, unread } = unreadFor(reader));
|
|
189
|
+
}
|
|
190
|
+
if (!unread.length) process.stdout.write('No new messages.\n');
|
|
191
|
+
unread.forEach(show);
|
|
192
|
+
prepare();
|
|
193
|
+
writeFileSync(inboxCursor(reader), String(count));
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const toRepoPath = (path) => relative(root, resolve(path)).split('\\').join('/');
|
|
197
|
+
|
|
198
|
+
const overlaps = (a, b) => a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
|
|
199
|
+
|
|
200
|
+
const activeClaims = () => {
|
|
201
|
+
const now = Date.now();
|
|
202
|
+
const claims = [];
|
|
203
|
+
for (const entry of entries()) {
|
|
204
|
+
if (entry.type === 'claim') {
|
|
205
|
+
for (const path of entry.paths) claims.push({ sender: entry.sender, path, expires: entry.expires });
|
|
206
|
+
} else if (entry.type === 'release') {
|
|
207
|
+
for (let index = claims.length - 1; index >= 0; index--) {
|
|
208
|
+
const claim = claims[index];
|
|
209
|
+
const released = !entry.paths?.length || entry.paths.some((path) => overlaps(path, claim.path));
|
|
210
|
+
if (claim.sender === entry.sender && released) claims.splice(index, 1);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return claims.filter((claim) => Date.parse(claim.expires) > now);
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
if (command === 'send' && sender) {
|
|
218
|
+
const message = words.length ? words.join(' ') : process.stdin.isTTY ? '' : readFileSync(0, 'utf8').trim();
|
|
219
|
+
if (!message) {
|
|
220
|
+
process.stderr.write('No message given.\n');
|
|
221
|
+
process.exitCode = 1;
|
|
222
|
+
} else {
|
|
223
|
+
append({ sender, message, ...(recipient ? { to: recipient } : {}) });
|
|
224
|
+
process.stdout.write(`Sent as ${sender}.\n`);
|
|
225
|
+
}
|
|
226
|
+
} else if (command === 'up') {
|
|
227
|
+
const withEnv = process.env.AGENT_MAILBOX ? `AGENT_MAILBOX=${quote(process.env.AGENT_MAILBOX)} ` : '';
|
|
228
|
+
startSession(DISPATCH_SESSION, `${withEnv}node ${quote(script)} dispatch`);
|
|
229
|
+
[sender, ...words].filter(Boolean).forEach((spec) => {
|
|
230
|
+
const [name, ...rest] = spec.split('=');
|
|
231
|
+
startSession(sessionFor(name), rest.join('=') || undefined);
|
|
232
|
+
});
|
|
233
|
+
} else if (command === 'run' && sender && words.length && !runsInSession(words.slice(1))) {
|
|
234
|
+
const [program, ...programArgs] = words;
|
|
235
|
+
process.exitCode = spawnSync(program, programArgs, { stdio: 'inherit' }).status ?? 1;
|
|
236
|
+
} else if (command === 'run' && sender && words.length) {
|
|
237
|
+
const withEnv = process.env.AGENT_MAILBOX ? `AGENT_MAILBOX=${quote(process.env.AGENT_MAILBOX)} ` : '';
|
|
238
|
+
if (!sessionExists(DISPATCH_SESSION))
|
|
239
|
+
startSession(DISPATCH_SESSION, `${withEnv}node ${quote(script)} dispatch`, true);
|
|
240
|
+
const session = freeSessionFor(sender);
|
|
241
|
+
const shellCommand = words.map(quote).join(' ');
|
|
242
|
+
if (process.env.TMUX) {
|
|
243
|
+
if (!sessionExists(session)) tmux('new-session', '-d', '-s', session, '-c', process.cwd(), shellCommand);
|
|
244
|
+
process.exitCode = spawnSync('tmux', ['switch-client', '-t', `=${session}`], { stdio: 'inherit' }).status ?? 1;
|
|
245
|
+
} else {
|
|
246
|
+
process.exitCode =
|
|
247
|
+
spawnSync('tmux', ['new-session', '-A', '-s', session, '-c', process.cwd(), shellCommand], { stdio: 'inherit' })
|
|
248
|
+
.status ?? 1;
|
|
249
|
+
}
|
|
250
|
+
} else if (command === 'shell-init' || command === 'shell-install') {
|
|
251
|
+
const agents = [sender, ...words].filter(Boolean);
|
|
252
|
+
const functions = (agents.length ? agents : ['claude', 'codex'])
|
|
253
|
+
.map(
|
|
254
|
+
(agent) => `${agent}() {
|
|
255
|
+
local root script
|
|
256
|
+
root=$(git rev-parse --show-toplevel 2>/dev/null) && script="$root/.agents/skills/ethlete-agent-sessions/agent-sessions.mjs"
|
|
257
|
+
if [ -n "$script" ] && [ -f "$script" ] && command -v tmux >/dev/null; then
|
|
258
|
+
node "$script" run ${agent} ${agent} "$@"
|
|
259
|
+
else
|
|
260
|
+
command ${agent} "$@"
|
|
261
|
+
fi
|
|
262
|
+
}
|
|
263
|
+
`,
|
|
264
|
+
)
|
|
265
|
+
.join('');
|
|
266
|
+
if (command === 'shell-init') {
|
|
267
|
+
process.stdout.write(functions);
|
|
268
|
+
} else {
|
|
269
|
+
const shell = basename(process.env.SHELL ?? '');
|
|
270
|
+
const file = rcFile ?? (shell === 'zsh' ? '.zshrc' : shell === 'bash' ? '.bashrc' : undefined);
|
|
271
|
+
if (!file) {
|
|
272
|
+
process.stderr.write('Unknown shell. Pass --file <path> to the startup file.\n');
|
|
273
|
+
process.exitCode = 1;
|
|
274
|
+
} else {
|
|
275
|
+
const path = rcFile ? resolve(rcFile) : join(homedir(), file);
|
|
276
|
+
const present = existsSync(path) ? readFileSync(path, 'utf8') : '';
|
|
277
|
+
const start = '# >>> ethlete agent-sessions >>>';
|
|
278
|
+
const end = '# <<< ethlete agent-sessions <<<';
|
|
279
|
+
const without = ['agent-mailbox', 'agent-sessions']
|
|
280
|
+
.reduce(
|
|
281
|
+
(text, name) =>
|
|
282
|
+
text.replace(new RegExp(`\\n?# >>> ethlete ${name} >>>[\\s\\S]*?# <<< ethlete ${name} <<<\\n?`), '\n'),
|
|
283
|
+
present,
|
|
284
|
+
)
|
|
285
|
+
.replace(/\n+$/, '\n');
|
|
286
|
+
const block = `${start}\n${functions}${end}\n`;
|
|
287
|
+
const next = remove ? without : `${without}\n${block}`;
|
|
288
|
+
if (dryRun) {
|
|
289
|
+
process.stdout.write(`Would write ${path}:\n${remove ? '(block removed)\n' : block}`);
|
|
290
|
+
} else {
|
|
291
|
+
writeFileSync(path, next);
|
|
292
|
+
process.stdout.write(
|
|
293
|
+
`${remove ? 'Removed the block from' : 'Wrote the block to'} ${path}. Open a new terminal.\n`,
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
} else if (command === 'restart' && sender && words.length) {
|
|
299
|
+
const session = [sessionFor(sender), sender].find(sessionExists);
|
|
300
|
+
const own = process.env.TMUX_PANE
|
|
301
|
+
? tmux('display-message', '-p', '-t', process.env.TMUX_PANE, '#{session_name}').stdout.trim()
|
|
302
|
+
: null;
|
|
303
|
+
if (!session) {
|
|
304
|
+
process.stderr.write(`No tmux session for ${sender}.\n`);
|
|
305
|
+
process.exitCode = 1;
|
|
306
|
+
} else if (own && own !== session) {
|
|
307
|
+
process.stderr.write(
|
|
308
|
+
`Refused: ${session} is not your session. You run in ${own}, so pass ${own.replace(`@${repoSlug}`, '')}.\n`,
|
|
309
|
+
);
|
|
310
|
+
process.exitCode = 1;
|
|
311
|
+
} else if (now) {
|
|
312
|
+
const type = (...keys) => tmux('send-keys', '-t', `=${session}:`, ...keys);
|
|
313
|
+
sleep(Number(afterSeconds ?? RESTART_AFTER_SECONDS) * 1000);
|
|
314
|
+
type('-l', sender.startsWith('codex') ? '/new' : '/clear');
|
|
315
|
+
sleep(ENTER_DELAY_MS);
|
|
316
|
+
type('Enter');
|
|
317
|
+
sleep(RESTART_CLEAR_MS);
|
|
318
|
+
type('-l', words.join(' '));
|
|
319
|
+
sleep(ENTER_DELAY_MS);
|
|
320
|
+
type('Enter');
|
|
321
|
+
} else {
|
|
322
|
+
const delay = Number(afterSeconds ?? RESTART_AFTER_SECONDS);
|
|
323
|
+
spawn(process.execPath, [script, 'restart', sender, '--now', '--after', String(delay), ...words], {
|
|
324
|
+
detached: true,
|
|
325
|
+
stdio: 'ignore',
|
|
326
|
+
}).unref();
|
|
327
|
+
process.stdout.write(`Restarting ${session} in ${delay} seconds. End your turn now.\n`);
|
|
328
|
+
}
|
|
329
|
+
} else if (command === 'down') {
|
|
330
|
+
[DISPATCH_SESSION, ...[sender, ...words].filter(Boolean).map(sessionFor)].forEach((name) => {
|
|
331
|
+
const result = tmux('kill-session', '-t', `=${name}`);
|
|
332
|
+
process.stdout.write(result.status === 0 ? `Stopped ${name}.\n` : `No session ${name}.\n`);
|
|
333
|
+
});
|
|
334
|
+
} else if (command === 'dispatch') {
|
|
335
|
+
prepare();
|
|
336
|
+
appendFileSync(mailbox, '');
|
|
337
|
+
let seen = entries().length;
|
|
338
|
+
const due = {};
|
|
339
|
+
const waiting = new Set();
|
|
340
|
+
const wake = (agent) => {
|
|
341
|
+
if (!unreadFor(agent).unread.length) return;
|
|
342
|
+
const text = `New mailbox message for ${agent}. Run: ${inboxCommand(agent)}`;
|
|
343
|
+
const session = [sessionFor(agent), agent].find(sessionExists);
|
|
344
|
+
if (!session) return process.stderr.write(`No tmux session for ${agent}.\n`);
|
|
345
|
+
if (hasDraft(session)) {
|
|
346
|
+
if (!waiting.has(agent)) process.stdout.write(`Waiting for the typed text in ${session}.\n`);
|
|
347
|
+
waiting.add(agent);
|
|
348
|
+
due[agent] = Date.now() + DRAFT_RETRY_MS;
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
waiting.delete(agent);
|
|
352
|
+
spawnSync('tmux', ['send-keys', '-t', `=${session}:`, '-l', text]);
|
|
353
|
+
sleep(ENTER_DELAY_MS);
|
|
354
|
+
spawnSync('tmux', ['send-keys', '-t', `=${session}:`, 'Enter']);
|
|
355
|
+
process.stdout.write(`Woke ${session}.\n`);
|
|
356
|
+
};
|
|
357
|
+
const poll = () => {
|
|
358
|
+
const history = entries();
|
|
359
|
+
const fresh = history.slice(seen);
|
|
360
|
+
seen = history.length;
|
|
361
|
+
fresh
|
|
362
|
+
.filter((entry) => entry.to && entry.to !== entry.sender)
|
|
363
|
+
.forEach((entry) => (due[entry.to] ??= Date.now() + WAKE_GRACE_MS));
|
|
364
|
+
Object.keys(due)
|
|
365
|
+
.filter((agent) => due[agent] <= Date.now())
|
|
366
|
+
.forEach((agent) => {
|
|
367
|
+
delete due[agent];
|
|
368
|
+
wake(agent);
|
|
369
|
+
});
|
|
370
|
+
};
|
|
371
|
+
process.stdout.write(`Dispatching for ${resolve(mailbox)}\n`);
|
|
372
|
+
setInterval(poll, 1000);
|
|
373
|
+
} else if (command === 'inbox' && sender) {
|
|
374
|
+
showInbox(sender, Number(waitSeconds ?? 0));
|
|
375
|
+
} else if (command === 'read') {
|
|
376
|
+
const history = entries();
|
|
377
|
+
(all ? history : history.slice(-Number(last ?? DEFAULT_LAST))).forEach(show);
|
|
378
|
+
} else if (command === 'claims') {
|
|
379
|
+
const claims = activeClaims();
|
|
380
|
+
if (!claims.length) process.stdout.write('No active claims.\n');
|
|
381
|
+
claims.forEach((claim) => process.stdout.write(`${claim.path} ${claim.sender} until ${claim.expires}\n`));
|
|
382
|
+
} else if (command === 'claim' && sender && words.length) {
|
|
383
|
+
showInbox(sender);
|
|
384
|
+
const paths = words.map(toRepoPath);
|
|
385
|
+
const conflicts = activeClaims().filter(
|
|
386
|
+
(claim) => claim.sender !== sender && paths.some((path) => overlaps(path, claim.path)),
|
|
387
|
+
);
|
|
388
|
+
if (conflicts.length && !force) {
|
|
389
|
+
conflicts.forEach((claim) =>
|
|
390
|
+
process.stderr.write(`Claimed by ${claim.sender}: ${claim.path} until ${claim.expires}\n`),
|
|
391
|
+
);
|
|
392
|
+
process.stderr.write('Ask for a handoff, or pass --force to claim anyway.\n');
|
|
393
|
+
process.exitCode = 2;
|
|
394
|
+
} else {
|
|
395
|
+
const hours = ttl === undefined ? DEFAULT_TTL_HOURS : Number(ttl);
|
|
396
|
+
const expires = new Date(Date.now() + hours * 3_600_000).toISOString();
|
|
397
|
+
append({ sender, type: 'claim', paths, expires, message: `claims ${paths.join(', ')}` });
|
|
398
|
+
process.stdout.write(`Claimed ${paths.join(', ')} as ${sender} until ${expires}.\n`);
|
|
399
|
+
}
|
|
400
|
+
} else if (command === 'release' && sender) {
|
|
401
|
+
const paths = words.map(toRepoPath);
|
|
402
|
+
append({
|
|
403
|
+
sender,
|
|
404
|
+
type: 'release',
|
|
405
|
+
paths,
|
|
406
|
+
message: paths.length ? `releases ${paths.join(', ')}` : 'releases all claims',
|
|
407
|
+
});
|
|
408
|
+
process.stdout.write(`Released as ${sender}.\n`);
|
|
409
|
+
} else if (command === 'watch' && sender) {
|
|
410
|
+
prepare();
|
|
411
|
+
appendFileSync(mailbox, '');
|
|
412
|
+
let consumed = 0;
|
|
413
|
+
let pending = '';
|
|
414
|
+
const update = () => {
|
|
415
|
+
const contents = readFileSync(mailbox, 'utf8');
|
|
416
|
+
if (contents.length < consumed) {
|
|
417
|
+
process.stderr.write('Mailbox was truncated; restarting read.\n');
|
|
418
|
+
consumed = 0;
|
|
419
|
+
pending = '';
|
|
420
|
+
}
|
|
421
|
+
pending += contents.slice(consumed);
|
|
422
|
+
consumed = contents.length;
|
|
423
|
+
const lines = pending.split('\n');
|
|
424
|
+
pending = lines.pop() ?? '';
|
|
425
|
+
lines
|
|
426
|
+
.map(parse)
|
|
427
|
+
.filter((entry) => entry && entry.sender !== sender)
|
|
428
|
+
.forEach(show);
|
|
429
|
+
};
|
|
430
|
+
process.stdout.write(`Watching ${resolve(mailbox)} as ${sender}\n`);
|
|
431
|
+
update();
|
|
432
|
+
watchFile(mailbox, { interval: 1000 }, update);
|
|
433
|
+
} else {
|
|
434
|
+
process.stdout.write(help);
|
|
435
|
+
if (command && command !== '--help') process.exitCode = 1;
|
|
436
|
+
}
|
|
@@ -45,7 +45,7 @@ finished while the user is still talking about it.
|
|
|
45
45
|
check that a variant renders. In a repository with `et design`, take it with
|
|
46
46
|
`et design shot <layer>/<name> --states [--variants]`: one contact sheet of every state, and
|
|
47
47
|
every variant of the open sketch, with a label per cell; below each variant row, a diff row marks
|
|
48
|
-
the pixels that differ from the merged state in red, or says "no pixel change" (with ", but the markup differs" when only the markup changed, e.g. both sides miss the same content). It prints the PNG path last. `--scale 2` enlarges a small part; `--crop x,y,w,h` cuts each cell to that CSS-pixel box, to judge hairlines. `--interaction hover` forces an interaction on every cell. `--state a,b` draws only those states, to compare them side by side. With `--variants`, `--diff-props` compares each variant with the merged part drawn with the variant's state props, so changed props do not paint the diff red. It draws
|
|
48
|
+
the pixels that differ from the merged state in red, or says "no pixel change" (with ", but the markup differs" when only the markup changed, e.g. both sides miss the same content). It prints the PNG path last. `--scale 2` enlarges a small part; `--crop x,y,w,h` cuts each cell to that CSS-pixel box, to judge hairlines. `--interaction hover` forces an interaction on every cell. `--state a,b` draws only those states, to compare them side by side. With `--variants`, `--round r2` or `--variant i,j` draws only those variants next to the merged row, so the current round stays readable; it filters the picture, never the checks. With `--variants`, `--diff-props` compares each variant with the merged part drawn with the variant's state props, so changed props do not paint the diff red. It draws
|
|
49
49
|
undimmed, whatever Studio shows. Do not write a scratch script for batch shots.
|
|
50
50
|
|
|
51
51
|
If a second defect appears, add it to the open list and give it one line. Do not draw it.
|
|
@@ -119,6 +119,8 @@ et design sketch report <layer>/<name> --variant b --by <model id> "phone"
|
|
|
119
119
|
et design sketch report <layer>/<name> --variant b --by <model id> --done # you stopped drawing b; Studio shows its frames
|
|
120
120
|
et design sketch check [<layer>/<name>] [--fix] # each variant laid over the product
|
|
121
121
|
et design sketch ready <layer>/<name> --by <model id> # now the user can pick
|
|
122
|
+
et design sketch reject <layer>/<name> --variant b [--reason <text>] --by <model id> # only when the user rejected b; no pick or merge until reopened
|
|
123
|
+
et design sketch reopen <layer>/<name> --variant b [--reason <text>] --by <model id> # only when the user takes b back; the rejection stays in the history
|
|
122
124
|
```
|
|
123
125
|
|
|
124
126
|
Variants are alternatives. A variant never stands for a state of another variant: a hidden or
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: e2e-triage
|
|
3
|
+
description: Work through a failed Playwright e2e run - read the failures file the @ethlete/cli failure reporter writes, group the failures by error, fix the largest group first, and re-run only the specs that failed. Read when an e2e run fails with more than a few tests, when an `e2e-failures.jsonl` file is present, or before you start a full e2e run after a change.
|
|
4
|
+
kind: skill
|
|
5
|
+
scope: consumer
|
|
6
|
+
requires: ['@ethlete/cli']
|
|
7
|
+
vars: [docsBaseUrl]
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Triage a failed e2e run
|
|
11
|
+
|
|
12
|
+
Many failed tests rarely mean many bugs. In one real run, a single change in a shared pipe failed 61
|
|
13
|
+
tests. After the fix, 158 of the first 159 retried tests passed. So find the shared cause first, and
|
|
14
|
+
never fix failures one test at a time from the top of the log.
|
|
15
|
+
|
|
16
|
+
## Before the run
|
|
17
|
+
|
|
18
|
+
1. Check that `playwright.config.ts` lists `@ethlete/cli/playwright/failure-reporter` in `reporter`.
|
|
19
|
+
If not, add it next to the reporter that is there: `[['list'], ['@ethlete/cli/playwright/failure-reporter']]`.
|
|
20
|
+
The reporter writes one JSON line per failed attempt while the run goes on. The guide is
|
|
21
|
+
{%docsBaseUrl%}/cli/e2e.
|
|
22
|
+
2. Run the specs of the files you changed first. Start the full suite only when they pass. A full
|
|
23
|
+
run takes long, and one broken shared file fails it everywhere.
|
|
24
|
+
|
|
25
|
+
## Read the failures
|
|
26
|
+
|
|
27
|
+
The file is `e2e-failures.jsonl` next to `playwright.config.ts`, unless `E2E_FAILURES_FILE` or the
|
|
28
|
+
`outputFile` option of the reporter names another path. You can read it while the run goes on.
|
|
29
|
+
|
|
30
|
+
Each line has `test`, `location`, `project`, `status`, `retry`, `seconds`, `error`, `detail` and `context`.
|
|
31
|
+
`error` holds the first lines of the message. `detail` holds the first later line with a URL, for errors whose first lines do not name it, or `null`. `context` is the path of Playwright's
|
|
32
|
+
`error-context.md`, which holds the page snapshot at the failure.
|
|
33
|
+
|
|
34
|
+
Do not parse the console log of the run. It mixes the output of parallel workers, and it has no end
|
|
35
|
+
line if the run stops.
|
|
36
|
+
|
|
37
|
+
## Group, then fix the largest group
|
|
38
|
+
|
|
39
|
+
1. Normalize the `error` text of each line. Remove line and column numbers, timeouts in ms, ids,
|
|
40
|
+
hashes and dates. Keep the matcher, the locator and the expected value.
|
|
41
|
+
2. Group the lines by the normalized text. Count the distinct spec files per group, not only the lines.
|
|
42
|
+
A group that spans many spec files points at shared code: a component, a pipe, a fixture or a
|
|
43
|
+
test helper.
|
|
44
|
+
3. Take the largest group. Read one `context` file of it, then the code the error points at. Find
|
|
45
|
+
one cause that explains the whole group.
|
|
46
|
+
4. Fix that cause. Then re-run only the spec files of that group.
|
|
47
|
+
|
|
48
|
+
A group whose errors are a connection refused (`ERR_CONNECTION_REFUSED`, `ECONNREFUSED`) on a local mock or
|
|
49
|
+
`webServer` port is an infrastructure group, not a test verdict. Check that the server is still up,
|
|
50
|
+
restart it, and re-run those tests before you read them as failures.
|
|
51
|
+
|
|
52
|
+
Small groups that differ only in a locator often have the same cause. Check them after the large group
|
|
53
|
+
is fixed, because a fix often clears them too.
|
|
54
|
+
|
|
55
|
+
## Re-run only what failed
|
|
56
|
+
|
|
57
|
+
Take the spec file from `location`, without the `:line`. Collect each file once, then run:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
npx playwright test <file> <file> --project=<project> --no-deps
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
- Pass each file as its own argument. In zsh, an unquoted `$files` is one argument, and Playwright
|
|
64
|
+
then finds no tests.
|
|
65
|
+
- `--no-deps` skips the setup projects. Leave it out if the failure is in a setup project, or if the
|
|
66
|
+
setup state is gone.
|
|
67
|
+
- `--project` comes from the `project` field. Run one call per project.
|
|
68
|
+
|
|
69
|
+
## Retries and flaky tests
|
|
70
|
+
|
|
71
|
+
With `retries` on, a test that fails each attempt has one line per attempt (`retry` 0, 1, and so on).
|
|
72
|
+
A test with a line for `retry` 0 and no line for the last retry passed on a later attempt: it is flaky.
|
|
73
|
+
Report a flaky test, but fix the stable failures first.
|
|
74
|
+
|
|
75
|
+
## Rules
|
|
76
|
+
|
|
77
|
+
- Never skip a failed test, or mark it with `test.fail()` or `test.fixme()`, to make a run pass.
|
|
78
|
+
- Never change an assertion to match the wrong value the app shows now. A failed e2e test is a
|
|
79
|
+
signal first. If you think the test is wrong, say why, and ask before you change it.
|
|
80
|
+
- Report per group: the cause, the fix, the spec files you re-ran, and what still fails.
|
|
@@ -15,6 +15,10 @@ continue in a fresh session. Two modes:
|
|
|
15
15
|
- **save** ("handoff", "wrap up") - write a handoff file.
|
|
16
16
|
- **resume** ("continue from the handoff") - read one and continue the work.
|
|
17
17
|
|
|
18
|
+
Text after the slug or the name is an instruction from the user, in both modes. In save mode it is
|
|
19
|
+
for the session that resumes, so it must reach that session word for word. In resume mode it wins
|
|
20
|
+
over the Next steps of the file where the two conflict.
|
|
21
|
+
|
|
18
22
|
Name one invocation form to the user - your own - and never both. The skill is
|
|
19
23
|
installed as `ethlete-handoff`, so under Claude Code the commands are
|
|
20
24
|
`/ethlete-handoff` and `/ethlete-handoff resume [name]`; there is no `/handoff`.
|
|
@@ -89,7 +93,13 @@ noise, and it costs the next session the tokens to read it.
|
|
|
89
93
|
|
|
90
94
|
Then give a prompt the user can copy into the fresh session, in a code block: the next task, the
|
|
91
95
|
plan file or finding it lives in, and any rule of this session that the repo does not record. Keep
|
|
92
|
-
it to a few lines.
|
|
96
|
+
it to a few lines.
|
|
97
|
+
|
|
98
|
+
An open item counts as the next task: a known gap, an open finding, or an unbuilt step in the plan,
|
|
99
|
+
in the task, or in your own report of this session. "Everything waits for the user" is true only
|
|
100
|
+
when every open item needs a decision of the user. Then name each decision; do not stop silently.
|
|
101
|
+
In a `tmux` session, restart yourself with the next task as the prompt (see the next section) instead
|
|
102
|
+
of handing the prompt to the user.
|
|
93
103
|
|
|
94
104
|
Write `{%handoffDir%}/<slug>.md` where `<slug>` is a short kebab-case name for
|
|
95
105
|
the task (use the user-provided slug if they gave one). If the file exists,
|
|
@@ -152,12 +162,48 @@ map, not a transcript.
|
|
|
152
162
|
After writing, tell the user where it landed and that a fresh session should
|
|
153
163
|
resume from it.
|
|
154
164
|
|
|
165
|
+
## Clear and resume on your own
|
|
166
|
+
|
|
167
|
+
Run inside a `tmux` session started with the agent-sessions `run` command or the shell functions from `shell-install` (`echo $TMUX` is not empty and
|
|
168
|
+
the agent-sessions script `restart` finds the session)? Then you may clear and
|
|
169
|
+
resume yourself after the save, instead of asking the user to. You decide. Do it only if every line
|
|
170
|
+
is true:
|
|
171
|
+
|
|
172
|
+
- No sub-agent, workflow or background command still runs. A background command from the old
|
|
173
|
+
session reports back into the new one, and a sub-agent answer has no reader after the clear.
|
|
174
|
+
- No answer you wait for is open: a peer session, a mailbox reply, a CI run.
|
|
175
|
+
- The tree is committed, or the handoff file names every uncommitted change.
|
|
176
|
+
- The handoff file is saved and its Next steps are concrete.
|
|
177
|
+
|
|
178
|
+
If one line is false, finish or collect that first. If you cannot, hand the prompt to the user as
|
|
179
|
+
before. Then run, with the slug of the file you saved:
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
node <sessions-skill-dir>/agent-sessions.mjs restart <name> '<resume command> <slug> [<text after the slug>]'
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
If the user wrote text after the slug of the save command, append it to the prompt word for word.
|
|
186
|
+
Escape each `'` in it as `'\''`. The restart drops anything you leave out, and the fresh session has
|
|
187
|
+
no other way to see it.
|
|
188
|
+
|
|
189
|
+
`<resume command>` is your own resume form from the top of this guide, with the name this repo
|
|
190
|
+
installed the skill under: `/ethlete-handoff resume` under Claude Code, `ethlete-handoff resume`
|
|
191
|
+
under Codex. A repo that ships its own copy can name it `handoff`, so check the skill directory
|
|
192
|
+
name. A wrong name runs no skill, and the fresh session starts without the file.
|
|
193
|
+
|
|
194
|
+
`<name>` is the sender name of your own session. Read it first with `tmux display-message -p -t "$TMUX_PANE" '#{session_name}'` and drop the `@<repo>` part: `claude-2@ethlete-sdk` gives `claude-2`. Never take the name from an example: a second Claude session is `claude-2`, not `claude`. The command refuses a session you do not run in. It returns at once. It
|
|
195
|
+
types `/clear` (`/new` for a `codex` name) and the prompt into your pane a few seconds later, so
|
|
196
|
+
end your turn after you run it and do not start another tool call. The `/clear` wipes the pane, so
|
|
197
|
+
the user never sees text you write after the command. Before you run it, put the reason as the
|
|
198
|
+
first line of the handoff file: `Restarted because: <one line>`. End the turn with no text.
|
|
199
|
+
|
|
155
200
|
## Resume mode
|
|
156
201
|
|
|
157
202
|
1. List `{%handoffDir%}/`. Pick the file matching the given name, or the most
|
|
158
203
|
recently modified one if no name was given. If the directory is empty, say so
|
|
159
204
|
and stop.
|
|
160
|
-
2. Read the file fully.
|
|
205
|
+
2. Read the file fully. If its first line starts with `Restarted because:`, show that line to the
|
|
206
|
+
user first, then delete it from the file.
|
|
161
207
|
3. Verify reality still matches: current branch, `git status`, last commit. If
|
|
162
208
|
they diverge from the handoff (e.g. someone committed in between), say what
|
|
163
209
|
changed and adapt - the repo is the truth, the handoff is the guide.
|
|
@@ -86,6 +86,13 @@ Every task carries a `kind`, and the kind decides who acts:
|
|
|
86
86
|
3. Delete the task files you finished under `.ethlete/update`, and leave the ones you could not decide.
|
|
87
87
|
4. Tell the user, per task: what you changed, and what still needs their decision.
|
|
88
88
|
|
|
89
|
+
## Known traps
|
|
90
|
+
|
|
91
|
+
- **Angular 22: `a?.b` gives `undefined`, not `null`.** Once the `$safeNavigationMigration` flag is
|
|
92
|
+
gone, a pipe that treats `null` and `undefined` differently breaks at runtime, with no type error. A
|
|
93
|
+
pipe that resets its state on `undefined` returns `null`, and the template then throws on a member
|
|
94
|
+
read. Pass `null` explicitly: `(x?.store ?? null) | suspense`.
|
|
95
|
+
|
|
89
96
|
## Rules
|
|
90
97
|
|
|
91
98
|
- **Never invent a migration.** Only the tasks in `.ethlete/update` and the guides they link are real.
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -21,6 +21,7 @@ const cli_args_1 = require("./lib/cli-args");
|
|
|
21
21
|
const config_1 = require("./lib/config");
|
|
22
22
|
const git_flow_command_1 = require("./lib/git-flow-command");
|
|
23
23
|
const load_content_1 = require("./lib/load-content");
|
|
24
|
+
const sessions_command_1 = require("./lib/sessions-command");
|
|
24
25
|
const migrate_1 = require("./lib/migrate");
|
|
25
26
|
const output_style_command_1 = require("./lib/output-style-command");
|
|
26
27
|
const package_runner_1 = require("./lib/package-runner");
|
|
@@ -40,6 +41,9 @@ const USAGE = `ethlete-agents — compile @ethlete agent rules and skills into y
|
|
|
40
41
|
ethlete-agents timetrack Ask the running Timetrack app about Jira - it holds this
|
|
41
42
|
machine's credentials, so no repo needs a token
|
|
42
43
|
(status, instance, issue, search, project, create, log)
|
|
44
|
+
ethlete-agents sessions Run agents in tmux sessions of one checkout: message them, wake idle
|
|
45
|
+
ones, restart one with a prompt, claim paths (run, up, down, restart,
|
|
46
|
+
send, inbox, claim). The old name "mailbox" works too
|
|
43
47
|
ethlete-agents migrate Convert the repo to the AGENTS.md + .agents/skills layout:
|
|
44
48
|
CLAUDE.md content moves into AGENTS.md (CLAUDE.md becomes an
|
|
45
49
|
@AGENTS.md import), hand-written .claude/skills move to
|
|
@@ -112,6 +116,9 @@ const run = (argv) => {
|
|
|
112
116
|
switch (command) {
|
|
113
117
|
case 'git-flow':
|
|
114
118
|
return (0, git_flow_command_1.gitFlowCommand)({ root, argv: argv.slice(1) });
|
|
119
|
+
case 'sessions':
|
|
120
|
+
case 'mailbox':
|
|
121
|
+
return (0, sessions_command_1.sessionsCommand)({ argv: argv.slice(1) });
|
|
115
122
|
case 'output-style':
|
|
116
123
|
return (0, output_style_command_1.outputStyleCommand)({ argv: argv.slice(1) });
|
|
117
124
|
case 'timetrack':
|