@ethlete/agent-rules 0.1.0-next.31 → 0.1.0-next.33

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 (38) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +7 -1
  3. package/content/hooks/background-design-commands.py +178 -0
  4. package/content/hooks/builder-chat-words.py +269 -0
  5. package/content/hooks/context-warning.py +47 -19
  6. package/content/hooks/git-safety.py +208 -0
  7. package/content/mods/plugins/e2e-progress/.claude-plugin/plugin.json +1 -1
  8. package/content/rules/nx-layout.md +5 -2
  9. package/content/rules/subagent-models.md +2 -2
  10. package/content/skills/agent-sessions/SKILL.md +9 -5
  11. package/content/skills/agent-sessions/agent-sessions.mjs +283 -34
  12. package/content/skills/design-exploration/SKILL.md +47 -547
  13. package/content/skills/design-exploration/reference-check-and-notes.md +92 -0
  14. package/content/skills/design-exploration/reference-flows-and-wireframes.md +78 -0
  15. package/content/skills/design-exploration/reference-inbox-and-mcp.md +54 -0
  16. package/content/skills/design-exploration/reference-order-and-design-system.md +124 -0
  17. package/content/skills/design-exploration/reference-products-and-sketches.md +211 -0
  18. package/content/skills/design-exploration/reference-shots.md +25 -0
  19. package/content/skills/git-commit/SKILL.md +10 -0
  20. package/content/skills/grilling/SKILL.md +1 -0
  21. package/content/skills/handoff/SKILL.md +29 -6
  22. package/package.json +1 -1
  23. package/src/index.js +2 -1
  24. package/src/index.js.map +1 -1
  25. package/src/lib/git-flow-repair.d.ts +1 -1
  26. package/src/lib/git-flow-start.d.ts +1 -1
  27. package/src/lib/migrate.js +2 -1
  28. package/src/lib/migrate.js.map +1 -1
  29. package/src/lib/output-style-command.d.ts +1 -1
  30. package/src/lib/prettier.d.ts +21 -0
  31. package/src/lib/prettier.js +51 -0
  32. package/src/lib/prettier.js.map +1 -0
  33. package/src/lib/sync.d.ts +1 -1
  34. package/src/lib/sync.js +9 -2
  35. package/src/lib/sync.js.map +1 -1
  36. package/src/lib/targets/hooks-shared.d.ts +3 -3
  37. package/src/lib/targets/hooks-shared.js +58 -20
  38. package/src/lib/targets/hooks-shared.js.map +1 -1
@@ -36,8 +36,10 @@ const DRAFT_RETRY_MS = 2000;
36
36
  const RESTART_AFTER_SECONDS = 5;
37
37
  const RESTART_CLEAR_MS = 2500;
38
38
  const RESTART_POLL_MS = 2000;
39
- const RESTART_WAIT_MS = 30 * 60 * 1000;
39
+ const RESTART_WAIT_MS = Number(process.env.AGENT_RESTART_WAIT_MS ?? 30 * 60 * 1000);
40
+ const RESTART_DRAFT_NOTICE_MS = Number(process.env.AGENT_RESTART_DRAFT_NOTICE_MS ?? 2 * 60 * 1000);
40
41
  const RESTART_CLEAR_WAIT_MS = Number(process.env.AGENT_RESTART_CLEAR_WAIT_MS ?? 60 * 1000);
42
+ const RESTART_EXIT_WAIT_MS = Number(process.env.AGENT_RESTART_EXIT_WAIT_MS ?? 30 * 1000);
41
43
  const repoSlug = basename(root).replace(/[.:]/g, '-');
42
44
  const sessionFor = (agent) => `${agent}@${repoSlug}`;
43
45
  const DISPATCH_SESSION = process.env.AGENT_MAILBOX_DISPATCH_SESSION ?? `mailbox-dispatch@${repoSlug}`;
@@ -61,6 +63,9 @@ const remove = takeFlag(args, '--remove', false);
61
63
  const afterSeconds = takeFlag(args, '--after', true);
62
64
  const now = takeFlag(args, '--now', false);
63
65
  const dryRun = takeFlag(args, '--dry-run', false);
66
+ const role = takeFlag(args, '--role', true);
67
+ const notify = takeFlag(args, '--notify', true);
68
+ const newProcess = takeFlag(args, '--process', false);
64
69
  const [command, sender, ...words] = args;
65
70
 
66
71
  const help = `Shared agent mailbox: ${mailbox}
@@ -74,12 +79,18 @@ Claim: node ${script} claim <name> <path...> [--ttl <hours>] [--force]
74
79
  Release: node ${script} release <name> [path...] (no path: release every claim of <name>)
75
80
  Claims: node ${script} claims
76
81
  Up: node ${script} up <name>[=<command>]... (start a tmux session per agent, plus one that runs dispatch)
77
- Run: node ${script} run <name> <command...> (start or attach the agent in its tmux session; starts dispatch)
82
+ Run: node ${script} run <name> <command...> [--role builder] [--dry-run]
83
+ (start or attach the agent in its tmux session; starts dispatch;
84
+ the builder role sets ETHLETE_AGENT_ROLE and gives claude a terse system prompt)
78
85
  Install: node ${script} shell-install [<agent>...] [--file <path>] [--remove] [--dry-run]
79
86
  (write the shell functions into ~/.zshrc or ~/.bashrc)
80
87
  Shell: node ${script} shell-init [<agent>...] (print shell functions that wrap each agent with run; default: claude codex)
81
- Restart: node ${script} restart <name> [--after <seconds>] <prompt...>
82
- (type the clear command and then the prompt into the tmux session of <name>)
88
+ Restart: node ${script} restart <name> [--after <seconds>] [--notify <agent,...>] [--process] [--role builder|none] <prompt...>
89
+ (type the clear command and then the prompt into the tmux session of <name>;
90
+ a long wait on a draft and a give-up go to <name> and each --notify agent;
91
+ --process ends the agent and starts it again in the same pane with the prompt;
92
+ it keeps the role, else takes it from a builder-* handoff name, and refuses
93
+ a session with no role while another agent session runs in the repo)
83
94
  Down: node ${script} down [<name>...] (stop dispatch; stop the named sessions too)
84
95
  Dispatch: node ${script} dispatch (wake the tmux session named <agent> for each message sent --to <agent>)
85
96
 
@@ -94,10 +105,80 @@ Keep credentials and personal data out of the mailbox. No agent is started autom
94
105
 
95
106
  const tmux = (...tmuxArgs) => spawnSync('tmux', tmuxArgs, { encoding: 'utf8' });
96
107
  const quote = (text) => `'${text.replaceAll("'", "'\\''")}'`;
108
+ const BUILDER_PROMPT =
109
+ 'The user does not read this chat. Write no text between tool calls; end each turn with at most one line. Reports go to the grill session via SendMessage.';
110
+ const withRole = (words) =>
111
+ role === 'builder' && basename(words[0]) === 'claude' ? [...words, '--append-system-prompt', BUILDER_PROMPT] : words;
97
112
  const sessionExists = (name) => tmux('has-session', '-t', `=${name}`).status === 0;
113
+ const ROLE_OPTION = '@ethlete_role';
114
+ const COMMAND_OPTION = '@ethlete_command';
115
+ const agentCommandOf = (session) =>
116
+ tmux('show-options', '-v', '-t', `=${session}:`, COMMAND_OPTION).stdout.trim() ||
117
+ tmux('display-message', '-p', '-t', `=${session}:`, '#{pane_start_command}')
118
+ .stdout.trim()
119
+ .replace(/^"(.*)"$/, '$1');
120
+ const roleOf = (session) => tmux('show-options', '-v', '-t', `=${session}:`, ROLE_OPTION).stdout.trim() || null;
121
+ const arePaired = (a, b) => (roleOf(a) === 'builder') !== (roleOf(b) === 'builder');
98
122
  const ONE_SHOT_FLAGS = new Set(['-p', '--print', '-v', '--version', '-h', '--help']);
99
123
  const runsInSession = (agentArgs) =>
100
124
  Boolean(process.stdin.isTTY && process.stdout.isTTY) && !agentArgs.some((arg) => ONE_SHOT_FLAGS.has(arg));
125
+ const TEXT_VALUE_FLAGS = new Set(['--append-system-prompt', '--system-prompt']);
126
+ const withoutPrompt = (words) => {
127
+ const separator = words.indexOf('--');
128
+ if (separator > 0) return words.slice(0, separator);
129
+ const [previous, last] = words.slice(-2);
130
+ const isPrompt =
131
+ words.length > 1 &&
132
+ !last.startsWith('-') &&
133
+ (!previous.startsWith('-') || (/\s/.test(last) && !TEXT_VALUE_FLAGS.has(previous)));
134
+ return isPrompt ? words.slice(0, -1) : words;
135
+ };
136
+ const SHELL_WORD = /(?:'[^']*'|\\.|[^\s'\\])+/g;
137
+ const ENV_WORD = /^[A-Za-z_][A-Za-z0-9_]*=/;
138
+ const unquoteWord = (word) => word.replace(/'([^']*)'|\\(.)/g, (_, quoted, escaped) => quoted ?? escaped);
139
+ const commandWithoutPrompt = (command) => {
140
+ const tokens = [...command.matchAll(SHELL_WORD)];
141
+ const program = tokens.findIndex(([word]) => !ENV_WORD.test(word));
142
+ if (program < 0) return command;
143
+ const words = tokens.slice(program).map(([word]) => unquoteWord(word));
144
+ const kept = withoutPrompt(words).length;
145
+ return kept === words.length ? command : command.slice(0, tokens[program + kept].index).trimEnd();
146
+ };
147
+ const HANDOFF_ROLE = /^\/handoff\s+resume\s+(builder|doc-agent)-/;
148
+ const handoffRoleOf = (prompt) => prompt.match(HANDOFF_ROLE)?.[1] ?? null;
149
+ const restartRoleOf = (session, prompt) => {
150
+ if (role === 'none') return null;
151
+ const found = role ?? roleOf(session) ?? handoffRoleOf(prompt);
152
+ return found === 'doc-agent' ? null : found;
153
+ };
154
+ const hasUnknownRole = (session, prompt) => {
155
+ if (role !== undefined || roleOf(session) || handoffRoleOf(prompt)) return false;
156
+ const peers = tmux('list-sessions', '-F', '#{session_name}')
157
+ .stdout.split('\n')
158
+ .filter((name) => name.endsWith(`@${repoSlug}`) && name !== session && name !== DISPATCH_SESSION);
159
+ return peers.length > 0 && !peers.some((name) => roleOf(name) === 'builder');
160
+ };
161
+ const commandWithRole = (command, agentRole) => {
162
+ const tokens = [...command.matchAll(SHELL_WORD)].map(([word]) => word);
163
+ const program = tokens.findIndex((word) => !ENV_WORD.test(word));
164
+ const env = program < 0 ? tokens : tokens.slice(0, program);
165
+ const words = tokens.slice(env.length).map(unquoteWord);
166
+ const kept = words.filter(
167
+ (word, index) =>
168
+ !(word === '--append-system-prompt' && words[index + 1] === BUILDER_PROMPT) &&
169
+ !(word === BUILDER_PROMPT && words[index - 1] === '--append-system-prompt'),
170
+ );
171
+ const rule =
172
+ agentRole === 'builder' && basename(kept[0] ?? '') === 'claude' ? ['--append-system-prompt', BUILDER_PROMPT] : [];
173
+ return [
174
+ ...env.filter((word) => !word.startsWith('ETHLETE_AGENT_ROLE=')),
175
+ `ETHLETE_AGENT_ROLE=${agentRole}`,
176
+ ...[...kept, ...rule].map(quote),
177
+ ].join(' ');
178
+ };
179
+ const bypassingShellFunction = (base) => base.replace(/^((?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*)/, '$1command ');
180
+ const EXIT_DIALOG_JOBS = /will stop when you exit/i;
181
+ const EXIT_AND_STOP = /exit and stop/i;
101
182
 
102
183
  /**
103
184
  * Reads the last prompt line (`❯` for Claude Code, `›` for Codex) of the pane. Dim text is the empty
@@ -105,14 +186,31 @@ const runsInSession = (agentArgs) =>
105
186
  * wake-up line from answering a permission dialog.
106
187
  */
107
188
  const SGR = `${String.fromCharCode(27)}\\[([0-9;]*)m`;
108
- const busyLine = (session) =>
109
- (tmux('capture-pane', '-p', '-t', `=${session}:`).stdout ?? '')
110
- .split('\n')
111
- .find((line) => /esc to inte/i.test(line)) ?? null;
189
+ const BUSY_TEXT = /esc to interrupt|Waiting for/i;
190
+ const isTranscript = (line) => /^\s*[●⎿]/.test(line);
191
+ /**
192
+ * The line that shows a running turn: the spinner line right above the input box, or a footer line below it.
193
+ * A transcript line (`●`, `⎿`) never counts, since an agent can end its turn with its own "Waiting for ..." text.
194
+ */
195
+ const busyLineOf = (lines) => {
196
+ const bottom = lastRuleIndex(lines);
197
+ const top = lines.slice(0, bottom).findLastIndex(isRule);
198
+ if (bottom === -1 || top === -1) return lines.find((line) => BUSY_TEXT.test(line) && !isTranscript(line)) ?? null;
199
+ const spinner = lines.slice(0, top).findLast((line) => line.trim() !== '') ?? '';
200
+ return [spinner, ...lines.slice(bottom + 1)].find((line) => BUSY_TEXT.test(line) && !isTranscript(line)) ?? null;
201
+ };
202
+ const busyLine = (session) => busyLineOf((tmux('capture-pane', '-p', '-t', `=${session}:`).stdout ?? '').split('\n'));
112
203
  const restartLog = (message) => {
113
204
  mkdirSync(dirname(mailbox), { recursive: true });
114
205
  appendFileSync(join(dirname(mailbox), 'restart.log'), `${new Date().toISOString()} ${message}\n`);
115
206
  };
207
+ const restartNotice = (message) => {
208
+ restartLog(message);
209
+ [sender, ...(notify ?? '').split(',')]
210
+ .map((agent) => agent.trim())
211
+ .filter(Boolean)
212
+ .forEach((to) => append({ sender: 'restart', to, message }));
213
+ };
116
214
 
117
215
  /**
118
216
  * Waits until the agent ended its turn and nobody types in its prompt, twice in a row; `false` after the time limit.
@@ -123,12 +221,19 @@ const waitForQuiet = (session, limitMs = RESTART_WAIT_MS, allowBusy = false) =>
123
221
  const end = Date.now() + limitMs;
124
222
  let quiet = 0;
125
223
  let reason = '';
224
+ let draftSince = null;
225
+ let told = false;
126
226
  while (Date.now() < end) {
127
227
  const busy = allowBusy ? null : busyLine(session);
128
228
  const draft = busy === null ? draftLine(session) : null;
129
229
  const next = busy !== null ? `busy: ${busy.trim()}` : draft !== null ? `draft: ${draft}` : '';
130
230
  if (next !== reason) restartLog(`${session} ${next ? `waits, ${next}` : 'is quiet'}`);
131
231
  reason = next;
232
+ draftSince = draft === null ? null : (draftSince ?? Date.now());
233
+ if (!told && draftSince !== null && Date.now() - draftSince >= RESTART_DRAFT_NOTICE_MS) {
234
+ told = true;
235
+ restartNotice(`restart of ${session} waits ${Math.round((Date.now() - draftSince) / 1000)}s, draft: ${draft}`);
236
+ }
132
237
  quiet = next ? 0 : quiet + 1;
133
238
  if (quiet === 2) return true;
134
239
  sleep(RESTART_POLL_MS);
@@ -138,15 +243,29 @@ const waitForQuiet = (session, limitMs = RESTART_WAIT_MS, allowBusy = false) =>
138
243
 
139
244
  /** The IDE context chip (`[⧉ In file.ts]`, `[⧉ 3 lines selected]`) shares the prompt line and survives `/clear`. */
140
245
  const IDE_CHIP = /\[⧉ [^\]]*\]/g;
141
- const draftLine = (session) => {
142
- const pane = tmux('capture-pane', '-p', '-e', '-t', `=${session}:`).stdout ?? '';
143
- const prompt = pane
144
- .split('\n')
145
- .reverse()
146
- .find((line) => /^\s*[❯›]/.test(line.replace(new RegExp(SGR, 'g'), '')));
147
- if (!prompt) return null;
246
+ const plain = (line) => line.replace(new RegExp(SGR, 'g'), '');
247
+ const isRule = (line) => /^─{10,}$/.test(plain(line).trim());
248
+ const isPromptMark = (line) => /^\s*[❯›]/.test(plain(line));
249
+ /** A row of the background-agent panel below the footer (`❯ ⏺ main`, ` ◯ general-purpose …`). */
250
+ const isPanelRow = (line) => /^\s*❯?\s*[⏺◯]/.test(plain(line));
251
+ const paneLines = (session) => (tmux('capture-pane', '-p', '-e', '-t', `=${session}:`).stdout ?? '').split('\n');
252
+ const lastRuleIndex = (lines) => lines.findLastIndex(isRule);
253
+ /**
254
+ * The prompt is the text between the last two rules of the input box. A `❯` line below the last rule that is not a
255
+ * panel row is a menu (`❯ 1. Yes`) and wins, which keeps a wake-up line from answering a permission dialog.
256
+ */
257
+ const promptLines = (lines) => {
258
+ const below = lastRuleIndex(lines);
259
+ const menu = lines.slice(below + 1).findLast((line) => isPromptMark(line) && !isPanelRow(line));
260
+ if (menu) return [menu];
261
+ const above = lines.slice(0, below).findLastIndex(isRule);
262
+ if (below !== -1 && above !== -1) return lines.slice(above + 1, below);
263
+ const prompt = lines.findLast((line) => isPromptMark(line) && !isPanelRow(line));
264
+ return prompt ? [prompt] : [];
265
+ };
266
+ const withoutDim = (line) => {
148
267
  let dim = false;
149
- const visible = prompt
268
+ return line
150
269
  .split(new RegExp(`(${SGR.replace('(', '(?:')})`))
151
270
  .map((part) => {
152
271
  const codes = part.match(new RegExp(`^${SGR}$`))?.[1].split(';');
@@ -158,12 +277,35 @@ const draftLine = (session) => {
158
277
  return '';
159
278
  })
160
279
  .join('');
161
- const text = visible
280
+ };
281
+ const draftLine = (session) => {
282
+ const text = promptLines(paneLines(session))
283
+ .map(withoutDim)
284
+ .join('\n')
162
285
  .replace(/^\s*[❯›]/, '')
163
286
  .replace(IDE_CHIP, '')
164
287
  .trim();
165
288
  return text.length > 0 ? text : null;
166
289
  };
290
+ const panelFocused = (lines) => lines.slice(lastRuleIndex(lines) + 1).some((line) => /^\s*❯\s*[⏺◯]/.test(plain(line)));
291
+ const PANEL_UP_LIMIT = 10;
292
+ /**
293
+ * Keys typed while the focus sits in the background-agent panel go into the panel. Up moves the focus back to the
294
+ * prompt, and an Up too many recalls history, so C-u clears the line after. `false` if the focus did not leave.
295
+ */
296
+ const leavePanel = (session) => {
297
+ if (!panelFocused(paneLines(session))) return true;
298
+ for (let press = 0; press < PANEL_UP_LIMIT; press++) {
299
+ tmux('send-keys', '-t', `=${session}:`, 'Up');
300
+ sleep(ENTER_DELAY_MS);
301
+ if (!panelFocused(paneLines(session))) {
302
+ tmux('send-keys', '-t', `=${session}:`, 'C-u');
303
+ sleep(ENTER_DELAY_MS);
304
+ return true;
305
+ }
306
+ }
307
+ return false;
308
+ };
167
309
  const hasDraft = (session) => draftLine(session) !== null;
168
310
  const isAttached = (name) => tmux('list-clients', '-t', `=${name}`).stdout.trim() !== '';
169
311
  const freeSessionFor = (agent) => {
@@ -271,22 +413,55 @@ if (command === 'send' && sender) {
271
413
  const [name, ...rest] = spec.split('=');
272
414
  startSession(sessionFor(name), rest.join('=') || undefined);
273
415
  });
416
+ } else if (command === 'run' && sender && words.length && role !== undefined && role !== 'builder') {
417
+ process.stderr.write(`Unknown role ${role}. Only builder exists.\n`);
418
+ process.exitCode = 1;
419
+ } else if (command === 'run' && sender && words.length && dryRun) {
420
+ process.stdout.write(`${role ? `ETHLETE_AGENT_ROLE=${role} ` : ''}${withRole(words).map(quote).join(' ')}\n`);
274
421
  } else if (command === 'run' && sender && words.length && !runsInSession(words.slice(1))) {
275
- const [program, ...programArgs] = words;
276
- process.exitCode = spawnSync(program, programArgs, { stdio: 'inherit' }).status ?? 1;
422
+ const [program, ...programArgs] = withRole(words);
423
+ process.exitCode =
424
+ spawnSync(program, programArgs, {
425
+ stdio: 'inherit',
426
+ env: role ? { ...process.env, ETHLETE_AGENT_ROLE: role } : process.env,
427
+ }).status ?? 1;
277
428
  } else if (command === 'run' && sender && words.length) {
278
429
  const withEnv = process.env.AGENT_MAILBOX ? `AGENT_MAILBOX=${quote(process.env.AGENT_MAILBOX)} ` : '';
279
430
  if (!sessionExists(DISPATCH_SESSION))
280
431
  startSession(DISPATCH_SESSION, `${withEnv}node ${quote(script)} dispatch`, true);
281
432
  const session = freeSessionFor(sender);
282
- const shellCommand = words.map(quote).join(' ');
433
+ const roleEnv = role ? `ETHLETE_AGENT_ROLE=${role} ` : '';
434
+ const shellCommand = `${roleEnv}${withRole(words).map(quote).join(' ')}`;
435
+ const baseCommand = `${roleEnv}${withRole(withoutPrompt(words)).map(quote).join(' ')}`;
283
436
  if (process.env.TMUX) {
284
- if (!sessionExists(session)) tmux('new-session', '-d', '-s', session, '-c', process.cwd(), shellCommand);
285
- process.exitCode = spawnSync('tmux', ['switch-client', '-t', `=${session}`], { stdio: 'inherit' }).status ?? 1;
437
+ if (!sessionExists(session)) {
438
+ tmux('new-session', '-d', '-s', session, '-c', process.cwd(), shellCommand);
439
+ tmux('set-option', '-t', `=${session}:`, COMMAND_OPTION, baseCommand);
440
+ if (role) tmux('set-option', '-t', `=${session}:`, ROLE_OPTION, role);
441
+ }
442
+ const callerSession = tmux(
443
+ 'display-message',
444
+ '-p',
445
+ '-t',
446
+ process.env.TMUX_PANE ?? '',
447
+ '#{session_id}',
448
+ ).stdout.trim();
449
+ const [callerClient] = callerSession
450
+ ? tmux('list-clients', '-t', callerSession, '-F', '#{client_tty}').stdout.split('\n').filter(Boolean)
451
+ : [];
452
+ process.exitCode = callerClient
453
+ ? (spawnSync('tmux', ['switch-client', '-c', callerClient, '-t', `=${session}`], { stdio: 'inherit' }).status ??
454
+ 1)
455
+ : 0;
286
456
  } else {
287
- process.exitCode =
288
- spawnSync('tmux', ['new-session', '-A', '-s', session, '-c', process.cwd(), shellCommand], { stdio: 'inherit' })
289
- .status ?? 1;
457
+ const created = sessionExists(session)
458
+ ? []
459
+ : ['new-session', '-d', '-s', session, '-c', process.cwd(), shellCommand];
460
+ if (created.length && tmux(...created).status === 0) {
461
+ tmux('set-option', '-t', `=${session}:`, COMMAND_OPTION, baseCommand);
462
+ if (role) tmux('set-option', '-t', `=${session}:`, ROLE_OPTION, role);
463
+ }
464
+ process.exitCode = spawnSync('tmux', ['attach-session', '-t', `=${session}`], { stdio: 'inherit' }).status ?? 1;
290
465
  }
291
466
  } else if (command === 'shell-init' || command === 'shell-install') {
292
467
  const agents = [sender, ...words].filter(Boolean);
@@ -344,30 +519,96 @@ if (command === 'send' && sender) {
344
519
  if (!session) {
345
520
  process.stderr.write(`No tmux session for ${sender}.\n`);
346
521
  process.exitCode = 1;
347
- } else if (own && own !== session) {
522
+ } else if (own && own !== session && !arePaired(own, session)) {
348
523
  process.stderr.write(
349
524
  `Refused: ${session} is not your session. You run in ${own}, so pass ${own.replace(`@${repoSlug}`, '')}.\n`,
350
525
  );
351
526
  process.exitCode = 1;
527
+ } else if (role !== undefined && role !== 'builder' && role !== 'none') {
528
+ process.stderr.write(`Unknown role ${role}. Pass --role builder or --role none.\n`);
529
+ process.exitCode = 1;
530
+ } else if (newProcess && hasUnknownRole(session, words.join(' '))) {
531
+ process.stderr.write(
532
+ `Refused: ${session} has no role and another agent session runs in this repo, so the new process could lose the builder rule. Resume a builder-* or doc-agent-* handoff, or pass --role builder or --role none.\n`,
533
+ );
534
+ process.exitCode = 1;
352
535
  } else if (now) {
353
536
  const type = (...keys) => tmux('send-keys', '-t', `=${session}:`, ...keys);
354
537
  sleep(Number(afterSeconds ?? RESTART_AFTER_SECONDS) * 1000);
355
538
  if (!waitForQuiet(session)) {
539
+ restartNotice(`restart of ${session} gave up after ${RESTART_WAIT_MS / 1000}s, reason in restart.log`);
356
540
  process.exitCode = 1;
541
+ } else if (!leavePanel(session)) {
542
+ restartNotice(
543
+ `restart of ${session} gave up, the focus did not leave the agent panel after ${PANEL_UP_LIMIT} Up`,
544
+ );
545
+ process.exitCode = 1;
546
+ } else if (newProcess) {
547
+ const pane = `=${session}:`;
548
+ const restartRole = restartRoleOf(session, words.join(' '));
549
+ const stored = commandWithoutPrompt(agentCommandOf(session));
550
+ const base = restartRole ? commandWithRole(stored, restartRole) : stored;
551
+ if (restartRole) tmux('set-option', '-t', pane, ROLE_OPTION, restartRole);
552
+ tmux('set-option', '-p', '-t', pane, 'remain-on-exit', 'on');
553
+ type('-l', sender.startsWith('codex') ? '/quit' : '/exit');
554
+ sleep(ENTER_DELAY_MS);
555
+ type('Enter');
556
+ const end = Date.now() + RESTART_EXIT_WAIT_MS;
557
+ const dead = () => tmux('display-message', '-p', '-t', pane, '#{pane_dead}').stdout.trim() === '1';
558
+ const answerExitDialog = () => {
559
+ const lines = (tmux('capture-pane', '-p', '-t', pane).stdout ?? '').split('\n');
560
+ const start = lines.findIndex((line) => EXIT_DIALOG_JOBS.test(line));
561
+ const stop = lines.findIndex((line, index) => index > start && EXIT_AND_STOP.test(line));
562
+ if (start < 0 || stop < 0) return false;
563
+ const selected = lines.findIndex((line, index) => index > start && line.trim().startsWith('❯'));
564
+ const jobs = lines
565
+ .slice(start + 1, stop)
566
+ .map((line) => line.trim())
567
+ .filter(Boolean);
568
+ restartNotice(
569
+ `restart of ${session} stopped its background work, start it again: ${jobs.join('; ') || '(none listed)'}`,
570
+ );
571
+ const between = (from, to) => lines.slice(from, to).filter((line) => line.trim()).length;
572
+ const [key, steps] = selected > stop ? ['Up', between(stop, selected)] : ['Down', between(selected, stop)];
573
+ for (let step = 0; selected >= 0 && step < steps; step++) type(key);
574
+ type('Enter');
575
+ return true;
576
+ };
577
+ let answered = false;
578
+ while (!dead() && Date.now() < end) {
579
+ if (!answered) answered = answerExitDialog();
580
+ sleep(RESTART_POLL_MS / 4);
581
+ }
582
+ if (!dead()) restartLog(`${session} did not exit ${RESTART_EXIT_WAIT_MS / 1000}s after the exit command, killed`);
583
+ const shell = process.env.SHELL || 'sh';
584
+ tmux(
585
+ 'respawn-pane',
586
+ '-k',
587
+ '-t',
588
+ pane,
589
+ '-c',
590
+ root,
591
+ shell,
592
+ '-lic',
593
+ `${bypassingShellFunction(base)} ${quote(words.join(' '))}`,
594
+ );
595
+ tmux('set-option', '-p', '-t', pane, 'remain-on-exit', 'off');
596
+ tmux('set-option', '-t', pane, COMMAND_OPTION, base);
597
+ restartLog(`${session} started a new process: ${base}`);
357
598
  } else {
358
599
  type('-l', sender.startsWith('codex') ? '/new' : '/clear');
359
600
  sleep(ENTER_DELAY_MS);
360
601
  type('Enter');
361
602
  sleep(RESTART_CLEAR_MS);
362
- if (waitForQuiet(session, RESTART_CLEAR_WAIT_MS, true)) {
603
+ if (waitForQuiet(session, RESTART_CLEAR_WAIT_MS, true) && leavePanel(session)) {
363
604
  type('-l', words.join(' '));
364
605
  sleep(ENTER_DELAY_MS);
365
606
  type('Enter');
366
607
  } else {
367
608
  const saved = join(dirname(mailbox), `restart.prompt.${session}.txt`);
368
609
  writeFileSync(saved, `${words.join(' ')}\n`);
369
- restartLog(
370
- `${session} prompt not empty ${RESTART_CLEAR_WAIT_MS / 1000}s after the clear, prompt not typed, saved to ${saved}`,
610
+ restartNotice(
611
+ `restart of ${session} gave up: prompt not empty ${RESTART_CLEAR_WAIT_MS / 1000}s after the clear, prompt not typed, saved to ${saved}`,
371
612
  );
372
613
  tmux(
373
614
  'display-message',
@@ -379,13 +620,20 @@ if (command === 'send' && sender) {
379
620
  }
380
621
  }
381
622
  } else {
623
+ if (own && own !== session) restartLog(`peer restart: ${own} restarted ${session}`);
382
624
  const delay = Number(afterSeconds ?? RESTART_AFTER_SECONDS);
383
- spawn(process.execPath, [script, 'restart', sender, '--now', '--after', String(delay), ...words], {
384
- detached: true,
385
- stdio: 'ignore',
386
- }).unref();
625
+ const forward = notify ? ['--notify', notify] : [];
626
+ const mode = [...(newProcess ? ['--process'] : []), ...(role ? ['--role', role] : [])];
627
+ spawn(
628
+ process.execPath,
629
+ [script, 'restart', sender, '--now', '--after', String(delay), ...forward, ...mode, ...words],
630
+ {
631
+ detached: true,
632
+ stdio: 'ignore',
633
+ },
634
+ ).unref();
387
635
  process.stdout.write(
388
- `Restarting ${session} once its turn ended and its prompt is empty, ${delay} seconds at the earliest. End your turn now.\n`,
636
+ `${newProcess ? 'Starting a new process in' : 'Restarting'} ${session} once its turn ended and its prompt is empty, ${delay} seconds at the earliest. End your turn now.\n`,
389
637
  );
390
638
  }
391
639
  } else if (command === 'down') {
@@ -411,6 +659,7 @@ if (command === 'send' && sender) {
411
659
  return;
412
660
  }
413
661
  waiting.delete(agent);
662
+ if (!leavePanel(session)) return process.stderr.write(`The focus of ${session} stays in the agent panel.\n`);
414
663
  spawnSync('tmux', ['send-keys', '-t', `=${session}:`, '-l', text]);
415
664
  sleep(ENTER_DELAY_MS);
416
665
  spawnSync('tmux', ['send-keys', '-t', `=${session}:`, 'Enter']);