@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.
- package/CHANGELOG.md +50 -0
- package/README.md +7 -1
- package/content/hooks/background-design-commands.py +178 -0
- package/content/hooks/builder-chat-words.py +269 -0
- package/content/hooks/context-warning.py +47 -19
- package/content/hooks/git-safety.py +208 -0
- package/content/mods/plugins/e2e-progress/.claude-plugin/plugin.json +1 -1
- package/content/rules/nx-layout.md +5 -2
- package/content/rules/subagent-models.md +2 -2
- package/content/skills/agent-sessions/SKILL.md +9 -5
- package/content/skills/agent-sessions/agent-sessions.mjs +283 -34
- package/content/skills/design-exploration/SKILL.md +47 -547
- package/content/skills/design-exploration/reference-check-and-notes.md +92 -0
- package/content/skills/design-exploration/reference-flows-and-wireframes.md +78 -0
- package/content/skills/design-exploration/reference-inbox-and-mcp.md +54 -0
- package/content/skills/design-exploration/reference-order-and-design-system.md +124 -0
- package/content/skills/design-exploration/reference-products-and-sketches.md +211 -0
- package/content/skills/design-exploration/reference-shots.md +25 -0
- package/content/skills/git-commit/SKILL.md +10 -0
- package/content/skills/grilling/SKILL.md +1 -0
- package/content/skills/handoff/SKILL.md +29 -6
- package/package.json +1 -1
- package/src/index.js +2 -1
- package/src/index.js.map +1 -1
- package/src/lib/git-flow-repair.d.ts +1 -1
- package/src/lib/git-flow-start.d.ts +1 -1
- package/src/lib/migrate.js +2 -1
- package/src/lib/migrate.js.map +1 -1
- package/src/lib/output-style-command.d.ts +1 -1
- package/src/lib/prettier.d.ts +21 -0
- package/src/lib/prettier.js +51 -0
- package/src/lib/prettier.js.map +1 -0
- package/src/lib/sync.d.ts +1 -1
- package/src/lib/sync.js +9 -2
- package/src/lib/sync.js.map +1 -1
- package/src/lib/targets/hooks-shared.d.ts +3 -3
- package/src/lib/targets/hooks-shared.js +58 -20
- 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...>
|
|
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
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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))
|
|
285
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
370
|
-
|
|
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
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
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']);
|