@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.
Files changed (42) hide show
  1. package/AGENTS.md +28 -0
  2. package/CHANGELOG.md +114 -0
  3. package/CLAUDE.md +1 -0
  4. package/THIRD-PARTY-LICENSES.md +1 -1
  5. package/content/hooks/context-warning.py +46 -7
  6. package/content/mods/.claude-plugin/marketplace.json +11 -0
  7. package/content/mods/plugins/e2e-progress/.claude-plugin/plugin.json +6 -0
  8. package/content/mods/plugins/e2e-progress/hooks/hooks.json +1 -0
  9. package/content/mods/plugins/e2e-progress/hooks/register.tsx +442 -0
  10. package/content/mods/plugins/e2e-progress/tsconfig.json +3 -0
  11. package/content/mods/plugins/e2e-progress/types/index.d.ts +23 -0
  12. package/content/rules/agent-sessions-wake-up.md +11 -0
  13. package/content/rules/lint-and-format.md +2 -1
  14. package/content/rules/subagent-models.md +4 -0
  15. package/content/skills/agent-sessions/SKILL.md +81 -0
  16. package/content/skills/agent-sessions/agent-sessions.mjs +436 -0
  17. package/content/skills/design-exploration/SKILL.md +429 -135
  18. package/content/skills/e2e-triage/SKILL.md +80 -0
  19. package/content/skills/git-commit/SKILL.md +5 -0
  20. package/content/skills/grilling/SKILL.md +17 -0
  21. package/content/skills/handoff/SKILL.md +51 -1
  22. package/content/skills/sdk-update/SKILL.md +7 -0
  23. package/package.json +1 -1
  24. package/src/index.js +7 -0
  25. package/src/index.js.map +1 -1
  26. package/src/lib/config.d.ts +2 -0
  27. package/src/lib/config.js +5 -3
  28. package/src/lib/config.js.map +1 -1
  29. package/src/lib/plan.js +4 -0
  30. package/src/lib/plan.js.map +1 -1
  31. package/src/lib/sessions-command.d.ts +4 -0
  32. package/src/lib/sessions-command.js +16 -0
  33. package/src/lib/sessions-command.js.map +1 -0
  34. package/src/lib/targets/claude-hooks.d.ts +2 -0
  35. package/src/lib/targets/claude-hooks.js +4 -1
  36. package/src/lib/targets/claude-hooks.js.map +1 -1
  37. package/src/lib/targets/claude-mods.d.ts +20 -0
  38. package/src/lib/targets/claude-mods.js +47 -0
  39. package/src/lib/targets/claude-mods.js.map +1 -0
  40. package/src/lib/targets/hooks-shared.d.ts +1 -0
  41. package/src/lib/targets/hooks-shared.js +3 -2
  42. 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
+ }