@assuredloop/orca-bot-kit 0.27.0 → 0.29.0

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/src/run.js ADDED
@@ -0,0 +1,155 @@
1
+ // `obk run --timeout <seconds> -- <command…>`: one command run with a hard time
2
+ // limit (#532). macOS has no `timeout`, and the fleet's form for a bounded run,
3
+ // `perl -e 'alarm …; exec @ARGV' -- <command>`, cannot be allowed by a narrow
4
+ // rule, since perl runs whatever its arguments say. A rule for this command
5
+ // names the real command after `--`, and the kit judges that command as a rule
6
+ // of its own (permissions.js). It runs whatever its arguments say too, so it is
7
+ // never in the default set (ADR 0050).
8
+ //
9
+ // The command runs with no shell, in a process group of its own, so the time
10
+ // limit stops what it started as well. Its exit code comes back as it is; one
11
+ // ended by a signal the kit did not send comes back as 128 and the signal's
12
+ // number, as a shell gives it.
13
+
14
+ import { spawn } from 'node:child_process';
15
+ import { constants } from 'node:os';
16
+
17
+ /** The exit code of a run the time limit stopped, as GNU timeout gives it. */
18
+ const TIMED_OUT = 124;
19
+
20
+ /** The exit codes a shell gives a command it cannot find, and one it cannot run. */
21
+ const NOT_FOUND = 127;
22
+ const CANNOT_RUN = 126;
23
+
24
+ /** How long the group has after SIGTERM before SIGKILL, as `retire` gives a session's processes (#537). */
25
+ const GRACE_MS = 3000;
26
+
27
+ /** How often the kit looks whether anything of the group still runs. */
28
+ const LOOK_MS = 100;
29
+
30
+ /** The longest time limit a timer holds, in seconds: about 24 days. */
31
+ const LONGEST = Math.floor((2 ** 31 - 1) / 1000);
32
+
33
+ /** The signals that end the kit and that it passes on to the command's group, so it is not left running. */
34
+ const PASSED_ON = ['SIGINT', 'SIGTERM', 'SIGHUP'];
35
+
36
+ const USE = 'obk run takes --timeout <seconds>, then --, then the command: obk run --timeout 300 -- go test ./...';
37
+
38
+ /**
39
+ * The time limit and the command from `obk run`'s arguments, or a refusal.
40
+ * Only `--timeout` goes before `--`; everything after it is the command's.
41
+ */
42
+ export function parseRun(args) {
43
+ const end = args.indexOf('--');
44
+ const options = end === -1 ? args : args.slice(0, end);
45
+ let value;
46
+ for (let at = 0; at < options.length; at += 1) {
47
+ const option = options[at];
48
+ if (option === '--timeout' && at + 1 < options.length && value === undefined) {
49
+ value = options[at + 1];
50
+ at += 1;
51
+ } else if (option.startsWith('--timeout=') && value === undefined) {
52
+ value = option.slice('--timeout='.length);
53
+ } else {
54
+ throw new Error(`run does not take ${option}. ${USE}. Nothing was run.`);
55
+ }
56
+ }
57
+ if (value === undefined) throw new Error(`run needs --timeout <seconds>: how long the command may run. ${USE}. Nothing was run.`);
58
+ if (!/^\d+$/.test(value) || Number(value) === 0 || Number(value) > LONGEST) {
59
+ throw new Error(`run's --timeout is a whole number of seconds from 1 to ${LONGEST}, and got: ${value === '' ? 'nothing' : value}. Nothing was run.`);
60
+ }
61
+ const command = end === -1 ? [] : args.slice(end + 1);
62
+ if (command.length === 0) throw new Error(`run needs the command after --. ${USE}. Nothing was run.`);
63
+ return { seconds: Number(value), command };
64
+ }
65
+
66
+ /**
67
+ * Run the command with its time limit, its input and output the caller's, and
68
+ * give back the exit code `obk run` exits with. The run ends when nothing of
69
+ * the command's group runs any more: a process the command started and left
70
+ * running is held to the same limit, and to a signal the kit passes on.
71
+ */
72
+ export function runBounded({ seconds, command }) {
73
+ return new Promise((resolve) => {
74
+ // `detached` makes the command the leader of a group of its own, so its
75
+ // pid, taken here at spawn, is the group's id.
76
+ const child = spawn(command[0], command.slice(1), { stdio: 'inherit', detached: true });
77
+ // The leader's exit code, once it has exited.
78
+ let exitCode;
79
+ let stopping = false;
80
+ let result;
81
+ let killAt;
82
+ let deadline;
83
+ let look;
84
+ const signal = (pid, name) => {
85
+ try {
86
+ process.kill(pid, name);
87
+ return true;
88
+ } catch {
89
+ return false;
90
+ }
91
+ };
92
+ // Each process of the group gets a signal once. The leader gets it by its
93
+ // own pid as well only when it may have left the group: when nothing is
94
+ // left in the group, or for SIGKILL, which no handler sees. And only
95
+ // while it has not exited, since its pid is free again after that.
96
+ const group = (name) => {
97
+ const sent = signal(-child.pid, name);
98
+ if (exitCode === undefined && name !== 0 && (!sent || name === 'SIGKILL')) signal(child.pid, name);
99
+ return sent;
100
+ };
101
+
102
+ const finish = (code) => {
103
+ clearTimeout(deadline);
104
+ clearTimeout(look);
105
+ for (const name of PASSED_ON) process.off(name, passOn);
106
+ resolve(code);
107
+ };
108
+
109
+ // Once the leader has exited or the group is being stopped: look until
110
+ // nothing of the group runs, and send SIGKILL when the grace is over.
111
+ const watch = () => {
112
+ if (exitCode !== undefined && !group(0)) return finish(result ?? exitCode);
113
+ if (killAt !== undefined && Date.now() >= killAt) {
114
+ group('SIGKILL');
115
+ killAt = undefined;
116
+ }
117
+ look = setTimeout(watch, LOOK_MS);
118
+ return undefined;
119
+ };
120
+
121
+ // Stop the group: `name` first, then SIGKILL to what still runs after the
122
+ // grace. obk run exits with `code`, or with the leader's own when it is
123
+ // undefined.
124
+ const stop = (name, code) => {
125
+ if (stopping) return;
126
+ stopping = true;
127
+ result = code;
128
+ clearTimeout(deadline);
129
+ group(name);
130
+ killAt = Date.now() + GRACE_MS;
131
+ clearTimeout(look);
132
+ watch();
133
+ };
134
+ const passOn = (name) => stop(name, undefined);
135
+
136
+ child.on('error', (error) => {
137
+ const missing = error.code === 'ENOENT';
138
+ process.stderr.write(`obk: run could not start ${command[0]}: ${missing ? 'there is no such command' : error.code ?? error.message}.\n`);
139
+ finish(missing ? NOT_FOUND : CANNOT_RUN);
140
+ });
141
+ if (child.pid === undefined) return;
142
+ for (const name of PASSED_ON) process.on(name, passOn);
143
+
144
+ child.on('exit', (code, name) => {
145
+ exitCode = code ?? 128 + constants.signals[name];
146
+ clearTimeout(look);
147
+ watch();
148
+ });
149
+
150
+ deadline = setTimeout(() => {
151
+ process.stderr.write(`obk: run stopped ${command.join(' ')}: it ran past its time limit of ${seconds} seconds, and its whole process group was stopped.\n`);
152
+ stop('SIGTERM', TIMED_OUT);
153
+ }, seconds * 1000);
154
+ });
155
+ }
package/src/skills.js CHANGED
@@ -24,11 +24,11 @@ import { fileURLToPath } from 'node:url';
24
24
  import { parse, parseDocument, stringify } from 'yaml';
25
25
 
26
26
  import { bookFile, readBook, takeLineTurn, TYPING_HELD, TYPING_WAIT_MS } from './book.js';
27
- import { botDir, botNames, changesExactly, leadsOutside, readBot, YAML_OUT } from './bot.js';
27
+ import { botDir, botNames, changesExactly, leadsOutside, readBot, refuseOutside, withBotTurn, writeWhole, YAML_OUT } from './bot.js';
28
28
  import { harnessOf, ownCli, shellWord } from './launch.js';
29
- import { orcaCli, orcaTrouble, tabToTypeInto, typeIntoTab } from './orca.js';
29
+ import { orcaTrouble, tabToTypeInto, typeIntoTab } from './orca.js';
30
30
  import { listIn } from './rules.js';
31
- import { cloneDir, isCloned, readSources, skillsIn, wrongClone } from './sources.js';
31
+ import { cloneDir, isCloned, leavesClone, readSources, skillsIn, wrongClone } from './sources.js';
32
32
 
33
33
  /** The kit's own skills, inside the installed package. */
34
34
  const KIT_SKILLS = fileURLToPath(new URL('../skills', import.meta.url));
@@ -84,25 +84,28 @@ export function addSkill(bots, bot, ref) {
84
84
  throw new Error(`there is no bot called ${bot} in ${bots}: ${file} is not there. Create it with obk bot create.`);
85
85
  }
86
86
 
87
- if (listIn(file, 'skills').includes(ref)) return { bot, home, skill: ref, state: 'there' };
88
-
89
- const was = readFileSync(file, 'utf8');
90
- const doc = parseDocument(was);
91
- const listed = doc.get('skills', true);
92
- if (listed?.items === undefined) {
93
- // No list to add to: an empty `skills:`, or no skills key at all.
94
- doc.set('skills', [ref]);
95
- } else {
96
- listed.flow = false;
97
- doc.addIn(['skills'], ref);
98
- }
87
+ return withBotTurn(home, () => {
88
+ if (listIn(file, 'skills').includes(ref)) return { bot, home, skill: ref, state: 'there' };
89
+ refuseOutside(home, file);
99
90
 
100
- const text = doc.toString(YAML_OUT);
101
- if (!changesExactly(was, text, (had) => ({ ...had, skills: [...(had.skills ?? []), ref] }))) {
102
- throw new Error(`${file} cannot have a skill added to it without changing something else in it, so nothing was written. Add ${ref} to its skills list by hand.`);
103
- }
104
- writeFileSync(file, text);
105
- return { bot, home, skill: ref, state: 'added' };
91
+ const was = readFileSync(file, 'utf8');
92
+ const doc = parseDocument(was);
93
+ const listed = doc.get('skills', true);
94
+ if (listed?.items === undefined) {
95
+ // No list to add to: an empty `skills:`, or no skills key at all.
96
+ doc.set('skills', [ref]);
97
+ } else {
98
+ listed.flow = false;
99
+ doc.addIn(['skills'], ref);
100
+ }
101
+
102
+ const text = doc.toString(YAML_OUT);
103
+ if (!changesExactly(was, text, (had) => ({ ...had, skills: [...(had.skills ?? []), ref] }))) {
104
+ throw new Error(`${file} cannot have a skill added to it without changing something else in it, so nothing was written. Add ${ref} to its skills list by hand.`);
105
+ }
106
+ writeWhole(file, text);
107
+ return { bot, home, skill: ref, state: 'added' };
108
+ });
106
109
  }
107
110
 
108
111
  /**
@@ -118,19 +121,22 @@ export function removeSkill(bots, bot, ref) {
118
121
  throw new Error(`there is no bot called ${bot} in ${bots}: ${file} is not there.`);
119
122
  }
120
123
 
121
- const index = listIn(file, 'skills').indexOf(ref);
122
- if (index === -1) return { bot, home, skill: ref, state: 'absent' };
124
+ return withBotTurn(home, () => {
125
+ const index = listIn(file, 'skills').indexOf(ref);
126
+ if (index === -1) return { bot, home, skill: ref, state: 'absent' };
127
+ refuseOutside(home, file);
123
128
 
124
- const was = readFileSync(file, 'utf8');
125
- const doc = parseDocument(was);
126
- doc.deleteIn(['skills', index]);
129
+ const was = readFileSync(file, 'utf8');
130
+ const doc = parseDocument(was);
131
+ doc.deleteIn(['skills', index]);
127
132
 
128
- const text = doc.toString(YAML_OUT);
129
- if (!changesExactly(was, text, (had) => ({ ...had, skills: had.skills.filter((one, at) => at !== index) }))) {
130
- throw new Error(`${file} cannot have a skill taken off it without changing something else in it, so nothing was written. Take ${ref} off its skills list by hand.`);
131
- }
132
- writeFileSync(file, text);
133
- return { bot, home, skill: ref, state: 'removed' };
133
+ const text = doc.toString(YAML_OUT);
134
+ if (!changesExactly(was, text, (had) => ({ ...had, skills: had.skills.filter((one, at) => at !== index) }))) {
135
+ throw new Error(`${file} cannot have a skill taken off it without changing something else in it, so nothing was written. Take ${ref} off its skills list by hand.`);
136
+ }
137
+ writeWhole(file, text);
138
+ return { bot, home, skill: ref, state: 'removed' };
139
+ });
134
140
  }
135
141
 
136
142
  /**
@@ -472,7 +478,10 @@ function tellSessions(home, bot, linked) {
472
478
  const told = { session: session.name, harness };
473
479
  const tab = typeof book[session.name]?.tab === 'string' ? book[session.name].tab : undefined;
474
480
  if (tab === undefined) return { ...told, state: 'not-up' };
475
- if (down !== undefined) return { ...told, state: 'unknown', trouble: `Orca is not answering at ${orcaCli()}, so the kit could not look at its tab, and nothing was typed` };
481
+ // Skills reach a Pi session in a later slice (#547), and nothing is typed
482
+ // into its tab before then.
483
+ if (harness === 'pi') return { ...told, state: 'unknown', trouble: 'it runs on Pi, and the kit does not give a Pi session skills yet, so nothing was typed' };
484
+ if (down !== undefined) return { ...told, state: 'unknown', trouble: `the kit could not look at its tab, and nothing was typed: ${down}` };
476
485
  let turn;
477
486
  try {
478
487
  // The session's turn for a line (#482), for the look and its line. Codex
@@ -564,7 +573,14 @@ function follow(bots, ref, sources) {
564
573
  }
565
574
  const wrong = wrongClone(bots, source);
566
575
  if (wrong !== undefined) throw new Error(`${ref}: ${wrong}`);
567
- return path.join(skillsIn(bots, source), after);
576
+ // The pinned clone is the only place a source's skill comes from. A path,
577
+ // a skill name or a link the repo committed can lead out of it (#571).
578
+ const inClone = path.join(skillsIn(bots, source), after);
579
+ const real = leavesClone(bots, named, inClone);
580
+ if (real !== undefined) {
581
+ throw new Error(`${ref} leads outside the clone of the source ${named}, to ${real}, and a skill from a source comes only from the version skills.yaml pins for it. Name a skill inside ${cloneDir(bots, named)} with no link out of it.`);
582
+ }
583
+ return inClone;
568
584
  },
569
585
  common: () => path.join(bots, 'skills', ref),
570
586
  // The user's own spelling: absolute, at their home, or read from the bots
package/src/sources.js CHANGED
@@ -14,11 +14,11 @@
14
14
  // file, so the difference is theirs to see.
15
15
 
16
16
  import { spawnSync } from 'node:child_process';
17
- import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
17
+ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
18
18
  import path from 'node:path';
19
19
  import { parseDocument } from 'yaml';
20
20
 
21
- import { changesExactly, requireBotsFolder, YAML_OUT } from './bot.js';
21
+ import { changesExactly, leadsOutside, requireBotsFolder, YAML_OUT } from './bot.js';
22
22
  import { ownCli, shellWord } from './launch.js';
23
23
 
24
24
  /** The user's file that lists where skills come from. */
@@ -65,6 +65,9 @@ export function addSource(bots, { name, repo, ref, path: inside }) {
65
65
  if (TAKEN.includes(name)) {
66
66
  throw new Error(`${name} is the kit's own shelf, which a bot names kit:<name>, so a source cannot be called that. Call it something else.`);
67
67
  }
68
+ if (inside !== undefined && !staysInRepo(inside)) {
69
+ throw new Error(`${inside} cannot be the path of the source ${name}: it leads out of the repo, and a path is a subfolder inside it, such as skills.`);
70
+ }
68
71
 
69
72
  const file = path.join(bots, SKILLS_YAML);
70
73
  // Read through the same reader every other command uses, so a file this one
@@ -151,7 +154,6 @@ function bring(bots, source, moving) {
151
154
  const wrong = wrongClone(bots, source);
152
155
  if (wrong !== undefined) {
153
156
  if (!moving) throw new Error(wrong);
154
- rmSync(dir, { recursive: true, force: true });
155
157
  return clone(bots, source, source.ref, false);
156
158
  }
157
159
 
@@ -174,11 +176,16 @@ function bring(bots, source, moving) {
174
176
  * Clone a source and check it out out of the way, and put it in place only once
175
177
  * both have worked. `<name>.fetching` is where it happens; a run that cannot
176
178
  * finish leaves that behind and not a source.
179
+ *
180
+ * A clone already there, from a repository the file no longer names, stays
181
+ * where it is until the new one is ready (#568). It may hold the user's own
182
+ * files, and a replacement that fails must not cost them.
177
183
  */
178
184
  function clone(bots, source, rev, recorded) {
179
185
  mkdirSync(sourcesDir(bots), { recursive: true });
180
186
  const dir = cloneDir(bots, source.name);
181
187
  const fetching = `${dir}.fetching`;
188
+ const replaced = `${dir}.replaced`;
182
189
 
183
190
  try {
184
191
  rmSync(fetching, { recursive: true, force: true });
@@ -186,7 +193,25 @@ function clone(bots, source, rev, recorded) {
186
193
  // repository and never one of git's own options, whatever it begins with.
187
194
  git(['clone', '--quiet', '--', source.repo, fetching], undefined, `${source.name}: ${source.repo} could not be cloned`);
188
195
  checkout(fetching, source, rev, recorded);
189
- renameSync(fetching, dir);
196
+ if (existsSync(dir)) {
197
+ rmSync(replaced, { recursive: true, force: true });
198
+ renameSync(dir, replaced);
199
+ try {
200
+ renameSync(fetching, dir);
201
+ } catch (error) {
202
+ renameSync(replaced, dir);
203
+ throw error;
204
+ }
205
+ // The new clone is in place, so the update has worked. An old copy that
206
+ // will not go stays beside it, where health names it as a leftover.
207
+ try {
208
+ rmSync(replaced, { recursive: true, force: true });
209
+ } catch {
210
+ // Nothing to undo.
211
+ }
212
+ } else {
213
+ renameSync(fetching, dir);
214
+ }
190
215
  } finally {
191
216
  rmSync(fetching, { recursive: true, force: true });
192
217
  }
@@ -250,6 +275,47 @@ function git(args, cwd, trouble) {
250
275
  return result.stdout.trim();
251
276
  }
252
277
 
278
+ /**
279
+ * Whether a source's path, as written, names a folder inside its repo: not an
280
+ * absolute path, and no `..` that climbs above the top (#571). A link inside the
281
+ * clone can still lead out; `skills build` follows it before linking.
282
+ */
283
+ const staysInRepo = (inside) => !path.isAbsolute(inside) && path.normalize(inside).split(path.sep)[0] !== '..';
284
+
285
+ /**
286
+ * Where a source's skill at `dir` really leads when a link takes it, or anything
287
+ * in it, out of the source's clone; undefined when all of it stays inside (#571).
288
+ * A harness reads any file in a skill, so the whole folder is walked, through
289
+ * the links that stay inside as well.
290
+ */
291
+ export function leavesClone(bots, name, dir) {
292
+ const clone = cloneDir(bots, name);
293
+ const seen = new Set();
294
+ const walk = (at) => {
295
+ const real = leadsOutside(clone, at);
296
+ if (real !== undefined) return real;
297
+ let entries;
298
+ try {
299
+ const where = realpathSync.native(at);
300
+ // A folder reached twice, through a link back into the skill, is walked once.
301
+ if (seen.has(where) || !statSync(where).isDirectory()) return undefined;
302
+ seen.add(where);
303
+ entries = readdirSync(where);
304
+ } catch (error) {
305
+ // Nothing there: whatever links it says so.
306
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return undefined;
307
+ // A folder the kit may not read can hold a link out, and nothing shows it.
308
+ throw new Error(`${name}: the kit could not read ${at} (${error.code ?? error.message}), so it cannot tell whether anything in the skill leads out of the source's clone, and it links nothing from there.`);
309
+ }
310
+ for (const entry of entries) {
311
+ const found = walk(path.join(at, entry));
312
+ if (found !== undefined) return found;
313
+ }
314
+ return undefined;
315
+ };
316
+ return walk(dir);
317
+ }
318
+
253
319
  /** Where a source's skills sit inside its clone: its root, or the subfolder it names. */
254
320
  export const skillsIn = (bots, source) => path.join(cloneDir(bots, source.name), source.path ?? '');
255
321
 
@@ -331,6 +397,9 @@ export function readSources(file) {
331
397
  if (source.path !== undefined && typeof source.path !== 'string') {
332
398
  throw new Error(`${file}: the source ${name} has a path that is not a subfolder inside the repo.`);
333
399
  }
400
+ if (source.path !== undefined && !staysInRepo(source.path)) {
401
+ throw new Error(`${file}: the source ${name} has the path ${source.path}, which leads out of the repo, and a path is a subfolder inside it, such as skills.`);
402
+ }
334
403
  return source;
335
404
  });
336
405
  }
package/src/temp.js CHANGED
@@ -75,6 +75,9 @@ export async function makeTemp(bots, { tab, ...given }) {
75
75
 
76
76
  const maker = bot.sessions.find((session) => session.name === caller.session);
77
77
  const { settings, chosen } = settingsFor(bot, maker, role?.option ?? {}, given);
78
+ if (chosen.harness.value === 'pi') {
79
+ throw new Error(`${name} would run on Pi, and a temporary session on Pi comes in a later slice of #547. Make it on claude or codex. Nothing was made.`);
80
+ }
78
81
  refuseWiderApproval(bots, caller, bot, maker, given.approval);
79
82
  refuseApprovalArgs(chosen.harness.value, given.extra_args);
80
83
  settings.name = name;
@@ -83,27 +86,42 @@ export async function makeTemp(bots, { tab, ...given }) {
83
86
  else settings.prompt_file = given.prompt_file ?? role.prompt_file;
84
87
  settings.work_dir = `work/${name}`;
85
88
 
86
- const written = bot.sessions.some((session) => session.name === name) ? undefined : writePromptFirst(bots, caller, settings);
87
89
  // bot.yaml and the book are written under the book's lock, so that a second
88
90
  // make from the same maker waits, and then sees this one (#464). addSession
89
91
  // refuses a name the bot already has, and a setting that will not work,
90
92
  // before it writes anything.
91
93
  const made = new Date().toISOString();
92
94
  let locked = false;
95
+ let written;
93
96
  let added;
94
97
  try {
95
98
  await updateBook(caller.home, (book) => {
96
99
  locked = true;
97
100
  refuseNested(bots, caller, book);
98
101
  if (role !== undefined) refuseCap(caller, role, book);
99
- added = addSession(bots, caller.bot, settings);
102
+ // The prompt file is written under the lock too, and only for a name the
103
+ // bot does not have yet: a make of the same name that waited on this one
104
+ // writes nothing, so it has nothing of this one's to remove (#572).
105
+ written = readBot(caller.home, caller.bot).sessions.some((session) => session.name === name) ? undefined : writePromptFirst(bots, caller, settings);
106
+ try {
107
+ added = addSession(bots, caller.bot, settings);
108
+ } catch (error) {
109
+ // Taken back while the lock is held: once it is let go, another make
110
+ // of this name may write the same file.
111
+ const left = removeWritten(written);
112
+ if (left !== undefined) throw promptLeft(error.message.replace(/ ?Nothing was made\.$/, ''), written, left);
113
+ throw error;
114
+ }
100
115
  const kind = role === undefined ? {} : { role: role.name, option: role.option.name };
101
116
  book.sessions[name] = { ...book.sessions[name], temporary: { maker: caller.session, made, ...kind } };
102
117
  });
103
118
  } catch (error) {
104
- // A refusal under the lock has written nothing but the prompt file.
105
- const refused = locked && added === undefined;
119
+ // A refusal under the lock has written nothing, or taken its prompt file back.
120
+ if (locked && added === undefined) throw error;
106
121
  const failed = `${caller.bot}/${name} could not be recorded in the book as temporary (${error.message})`;
122
+ // The prompt file goes while the session is still in bot.yaml, where it
123
+ // keeps any other make of its name from writing that file.
124
+ const left = removeWritten(written);
107
125
  // A session in bot.yaml that the book does not call temporary would be a
108
126
  // long-lived one to everything that reads it, its maker's retire included,
109
127
  // so it is taken back off rather than left that way.
@@ -114,15 +132,8 @@ export async function makeTemp(bots, { tab, ...given }) {
114
132
  throw new Error(`${failed}, and taking it back off bot.yaml failed too (${undo.message}). It is in bot.yaml as a session the book does not call temporary. Take it off with ${shellWord(ownCli())} retire --bots ${shellWord(bots)} --bot ${caller.bot} --session ${name}`);
115
133
  }
116
134
  }
117
- // Off bot.yaml, nothing is made; a prompt file that stays is only a file,
118
- // and nothing reads it (#393).
119
- const why = refused ? error.message.replace(/ ?Nothing was made\.$/, '') : `${failed}${added === undefined ? '.' : ', so it was taken back off bot.yaml.'}`;
120
- try {
121
- if (written !== undefined) rmSync(written, { force: true });
122
- } catch (left) {
123
- throw new Error(`${why} Nothing was made. Its start prompt ${written} could not be removed (${left.message}), and nothing reads it. Remove it with rm ${shellWord(written)}`);
124
- }
125
- if (refused) throw error;
135
+ const why = `${failed}${added === undefined ? '.' : ', so it was taken back off bot.yaml.'}`;
136
+ if (left !== undefined) throw promptLeft(why, written, left);
126
137
  throw new Error(`${why} Nothing was made. Run this again once the book can be written.`);
127
138
  }
128
139
 
@@ -178,7 +189,7 @@ function settingsFor(bot, maker, option, given) {
178
189
  /**
179
190
  * Refuse an approval wider than the widest of the maker's own and the bot's
180
191
  * `temp_approval`, which only the user's yes through `permission approval
181
- * --temps` widens (ADR 0041).
192
+ * --temps` widens (ADR 0051).
182
193
  */
183
194
  function refuseWiderApproval(bots, caller, bot, maker, asked) {
184
195
  if (asked === undefined) return;
@@ -316,6 +327,24 @@ function writePromptFirst(bots, caller, settings) {
316
327
  return file;
317
328
  }
318
329
 
330
+ /** Remove the start prompt's file `written`, when there is one, and answer why it could not be. */
331
+ function removeWritten(written) {
332
+ try {
333
+ if (written !== undefined) rmSync(written, { force: true });
334
+ } catch (error) {
335
+ return error;
336
+ }
337
+ return undefined;
338
+ }
339
+
340
+ /**
341
+ * The refusal `why` for a make whose start prompt's file `written` could not
342
+ * be removed (`left`). Off bot.yaml, nothing is made; a prompt file that stays
343
+ * is only a file, and nothing reads it (#393).
344
+ */
345
+ const promptLeft = (why, written, left) =>
346
+ new Error(`${why} Nothing was made. Its start prompt ${written} could not be removed (${left.message}), and nothing reads it. Remove it with rm ${shellWord(written)}`);
347
+
319
348
  /**
320
349
  * Retire a temporary session the caller made. Returns what `retireSession`
321
350
  * does, with the maker.
@@ -506,10 +535,11 @@ const TEACH_FOOT = /Enter to .*· Esc to cancel/;
506
535
  * captured live, with what answers it. Rows are compared trimmed, the pointer
507
536
  * `❯` taken out, so the pointer may sit on any one of them.
508
537
  *
509
- * Claude Code 2.1.283's form (#261's live test, #416) is answered with Esc,
510
- * which cancels it (Not now), never a return, which is Continue. Claude Code
511
- * 2.1.289's numbered list (#489's live run 1) is answered with "2. Not now",
512
- * never "1. Yes" or "3. Don't show again" (the architect's ruling on #489).
538
+ * Claude Code 2.1.283's form (#261's live test, #416) and 2.1.302's (#604) are
539
+ * answered with Esc, which cancels them (Not now), never a return, which is
540
+ * Continue. Claude Code 2.1.289's numbered list (#489's live run 1) is
541
+ * answered with "2. Not now", never "1. Yes" or "3. Don't show again" (the
542
+ * architect's ruling on #489).
513
543
  */
514
544
  const TEACH_SHAPES = [
515
545
  {
@@ -524,6 +554,21 @@ const TEACH_SHAPES = [
524
554
  'Continue',
525
555
  '←/→ to change usage · Enter to continue · Esc to cancel',
526
556
  ],
557
+ escape: true,
558
+ },
559
+ {
560
+ name: 'the 2.1.302 form',
561
+ rows: [
562
+ TEACH_TITLE,
563
+ 'Claude Code reads this project, your recent Claude sessions, and optionally your shell history and other',
564
+ 'repositories. Claude analyzes this data and customizes auto mode to make better decisions.',
565
+ 'How you use Claude here Mixed',
566
+ 'Also scan shell history false',
567
+ 'Also scan your other repos false',
568
+ 'Continue',
569
+ '←/→ to change · Enter to continue · Esc to cancel',
570
+ ],
571
+ escape: true,
527
572
  },
528
573
  {
529
574
  name: 'the 2.1.289 list',
@@ -597,7 +642,7 @@ const wholeScreen = (rows) => rows.filter((row) => row.trim() !== '').map((row)
597
642
  /**
598
643
  * Answer the first-run screen of a temporary session the caller made (#489):
599
644
  * Claude Code's Teach auto mode screen, in one of the shapes the kit knows,
600
- * matched exactly. The 2.1.283 form gets Esc. The 2.1.289 list gets arrows to
645
+ * matched exactly. The 2.1.283 and 2.1.302 forms get Esc. The 2.1.289 list gets arrows to
601
646
  * "2. Not now" where needed, a second look that the pointer is there and the
602
647
  * list is otherwise as it was, and only then a return. Anything else is refused with
603
648
  * nothing typed. Like `trustHooks`, it is one command a permission rule of its
@@ -628,7 +673,7 @@ function answerTeach(target, kind) {
628
673
  const hooks = rows.some((row) => row.includes(HOOKS_REVIEW)) ? ` A Codex session's "${HOOKS_REVIEW}" is answered with ${kind} trust-hooks.` : '';
629
674
  throw new Error(`${run}'s screen is not one ${kind} answer answers: ${seen.misfit}.${hooks} ${nothing} It shows:\n${wholeScreen(rows)}`);
630
675
  }
631
- if (seen.shape === TEACH_SHAPES[0]) {
676
+ if (seen.shape.escape) {
632
677
  send('\x1b');
633
678
  return 'Esc (Not now)';
634
679
  }