@assuredloop/orca-bot-kit 0.23.0 → 0.25.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/SETUP.md CHANGED
@@ -200,6 +200,7 @@ waiting for:
200
200
  | Codex's update offer, `1. Update now` | `\r` | return: its selection starts on **Update now**, which accepts it | Codex 0.156.1; seen on 0.157.1 (offering 0.158.0) with the selection on **Update now**, not pressed; 0.158.0 offered 0.160.0, taken on 2026-10-02 by Bot Father's restarts |
201
201
  | `[oh-my-zsh] Would you like to update?` | `n` | they update their own shell | oh-my-zsh bf77e35 |
202
202
  | Claude Code's `Teach auto mode about your environment?` | `\x1b` | Esc, which cancels it (**Not now**). On 2.1.283 it is a form, not a list: `How you use Claude here`, `Also scan shell history`, `Also scan your other repos`, `Continue`, and `Enter to continue · Esc to cancel`. **Never send a return**: Enter is **Continue**, which starts a scan of the project, recent Claude sessions and, by default, the machine's shell history | Not yet proven: Esc cancels in Claude Code 2.1.283's code, not yet seen live (#370); the form was seen live twice on 2.1.283, in #261's live test and #239's live run 1 (#416); 2.1.278 showed a numbered list, answered with 2. Not now |
203
+ | Claude Code 2.1.289's `Teach auto mode about your environment?`, a numbered list: `1. Yes`, `2. Not now`, `3. Don't show again`, and `Enter to confirm · Esc to cancel`, above the input box | `\x1b[B\r` | down, then return, to **2. Not now**: its selection starts on **1. Yes**. Read the screen after the down arrow, and send the return only with the selection on **2. Not now**. Never **1. Yes**, which starts the teach scan, and never **3. Don't show again** | Not yet proven: the list was seen live on 2.1.289 with its selection on 1. Yes, in #489's live run 1, and was not answered there |
203
204
 
204
205
  Each key counts on where the selection starts, as it did on the version named.
205
206
  Read the screen in front of you before you send: if the selection is somewhere
@@ -221,6 +222,27 @@ Codex's `/new` question (seen on 0.156.1, 0.157.1, 0.158.0 and 0.160.0) is alway
221
222
  home. The kit never makes a git worktree, so
222
223
  `2. New worktree` is never the answer.
223
224
 
225
+ A bot's own temporary sessions are answered by their maker, through the kit, so
226
+ that one narrow permission rule allows it rather than a rule to type into any
227
+ tab. On Codex's hooks review, the maker runs
228
+ `obk temp trust-hooks --bots <their path> --name <session>`. On Claude Code's
229
+ `Teach auto mode` screen, it runs `obk temp answer --bots <their path> --name <session>`.
230
+ That command reads the screen and answers only when it holds one of the two
231
+ shapes in the table exactly as the kit knows it: Esc for the 2.1.283 form, and
232
+ **2. Not now** for the 2.1.289 list. On the list it sends the arrows, reads the
233
+ screen again, and sends the return only with the selection on **2. Not now**.
234
+ It refuses any other screen, types nothing then, and prints what it saw.
235
+ Afterwards it checks that the screen has gone. In
236
+ auto mode the maker's bot needs this rule, with the kit's full CLI path for
237
+ `<kit>`, as the command's own output prints it:
238
+
239
+ ```text
240
+ Bash(<kit> temp answer:*)
241
+ ```
242
+
243
+ Ask the user, and only after their yes run
244
+ `obk bot change --bots <their path> --bot <bot> --allow 'Bash(<kit> temp answer:*)'`.
245
+
224
246
  **Anything you do not recognise: type nothing.** Tell them what is on the
225
247
  screen and which tab it is in, and wait. A keypress into a menu you have not read
226
248
  is how a harness quits back to the shell.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assuredloop/orca-bot-kit",
3
- "version": "0.23.0",
3
+ "version": "0.25.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -294,6 +294,7 @@ otherwise `/Applications/Orca.app/Contents/Resources/bin/orca`. A bare
294
294
  | Codex's update offer, `1. Update now` | `\r` | **Update now**, where it starts | Codex 0.156.1 |
295
295
  | `[oh-my-zsh] Would you like to update?` | `n` | no: the user updates it | oh-my-zsh bf77e35 |
296
296
  | Claude Code's `Teach auto mode…` | `\x1b` | Esc: Not now; no return | Not yet proven, below |
297
+ | The same, 2.1.289's list | `\x1b[B\r` | down, return: **2. Not now** | Not yet proven, below |
297
298
 
298
299
  Each key counts on where the selection starts on the version named. Read the
299
300
  screen before you send: if the selection is elsewhere or the options have
@@ -309,7 +310,10 @@ Claude Code's `Teach auto mode` on 2.1.283 is a form, not a list, ending
309
310
  Enter is Continue, which scans the project, recent sessions and the machine's
310
311
  shell history. Esc cancels it (Not now) in its code; that is not yet proven
311
312
  live, though the form was seen live twice on 2.1.283. On 2.1.278 it was a
312
- numbered list, answered with `2. Not now`. Codex's hooks
313
+ numbered list, answered with `2. Not now`, and on 2.1.289 it is one again:
314
+ `1. Yes`, `2. Not now`, `3. Don't show again`, above the input box, its
315
+ selection on 1. Yes (seen live, not answered, in #489). Go down to 2. Not now,
316
+ read the screen, and only then return. Codex's hooks
313
317
  question matters most. The kit's hook is how the book learns which
314
318
  conversation the session is running, and until it is answered the
315
319
  conversation has not started. Codex's trust applies to the repository root,
@@ -323,7 +327,13 @@ exception: answer it with `obk temp trust-hooks --bots <bots> --name
323
327
  <session>`, not with the keys. It reads the screen, chooses **Trust all and
324
328
  continue** only if the review is there, and checks that the review went. A
325
329
  permission rule can allow that one command, where it cannot allow keys sent
326
- into any tab.
330
+ into any tab. The same goes for Claude Code's `Teach auto mode` screen on a
331
+ temporary session you made: answer it with `obk temp answer --bots <bots>
332
+ --name <session>`. It answers only a shape in the table exactly as the kit
333
+ knows it (Esc on the 2.1.283 form, 2. Not now on the 2.1.289 list, the return
334
+ only after a second look), refuses any other screen, and checks that it went. Its
335
+ rule is `Bash(<kit> temp answer:*)`, `<kit>` the kit's CLI path as the
336
+ command prints it; ask the user before `obk bot change --allow` adds it.
327
337
 
328
338
  Where the kit says no session came up, the shell swallowed the launch line,
329
339
  usually while it was asking its own question. Answer the shell, then close
package/src/book.js CHANGED
@@ -152,8 +152,13 @@ function takeLock(home) {
152
152
  }
153
153
  }
154
154
 
155
- /** The lock on `file`, waited for `waitMs` at most: see `takeLock` for why it is this one. */
156
- function lockOn(file, waitMs) {
155
+ /**
156
+ * The lock on `file`, waited for `waitMs` at most: see `takeLock` for why it is
157
+ * this one. `how` is SQLite's: `IMMEDIATE`, one holder at a time; `SHARED`,
158
+ * any number at once, none while an `EXCLUSIVE` is held or waited for; and
159
+ * `EXCLUSIVE`, alone, once every `SHARED` holder has let go.
160
+ */
161
+ function lockOn(file, waitMs, how = 'IMMEDIATE') {
157
162
  mkdirSync(path.dirname(file), { recursive: true });
158
163
 
159
164
  const db = new DatabaseSync(file);
@@ -161,7 +166,13 @@ function lockOn(file, waitMs) {
161
166
  // A writer that arrives while another is working waits for it rather than
162
167
  // failing at once, and gives up saying so rather than waiting for ever.
163
168
  db.exec(`PRAGMA busy_timeout = ${waitMs}`);
164
- db.exec('BEGIN IMMEDIATE');
169
+ if (how === 'SHARED') {
170
+ // A reading transaction holds its shared lock from its first read.
171
+ db.exec('BEGIN');
172
+ db.prepare('SELECT count(*) FROM sqlite_master').get();
173
+ } else {
174
+ db.exec(`BEGIN ${how}`);
175
+ }
165
176
  } catch (error) {
166
177
  db.close();
167
178
  throw error;
@@ -233,6 +244,86 @@ export function takeMailboxTurn(home, session) {
233
244
  }
234
245
  }
235
246
 
247
+ /**
248
+ * A session's turn to have its Codex thread named (#480), beside its mailbox
249
+ * turn: `{ release }`, or undefined at once when another naming holds it, so
250
+ * that two turn ends close together type the name once.
251
+ */
252
+ export const takeNameTurn = (home, session) => turnOn(home, session, 'name', 0);
253
+
254
+ /**
255
+ * A session's typing turn (#480, the architect's ruling), for what the kit
256
+ * types into the session's tab one key at a time: no line of the kit's may
257
+ * land in between and send what is there with its own return. Two locks
258
+ * beside its mailbox turn: `typing`, the gate, held for the whole typing, and
259
+ * `lines`, taken alone once the lines already on their way have gone in.
260
+ * Returns `{ release }`, or undefined when it did not come within `waitMs`,
261
+ * or the lines in flight did not finish within LINES_WAIT_MS.
262
+ */
263
+ export function takeTypingTurn(home, session, waitMs) {
264
+ const gate = turnOn(home, session, 'typing', waitMs);
265
+ if (gate === undefined) return undefined;
266
+ let lines;
267
+ try {
268
+ lines = turnOn(home, session, 'lines', LINES_WAIT_MS, 'EXCLUSIVE');
269
+ } finally {
270
+ if (lines === undefined) gate.release();
271
+ }
272
+ if (lines === undefined) return undefined;
273
+ return {
274
+ release() {
275
+ try {
276
+ lines.release();
277
+ } finally {
278
+ gate.release();
279
+ }
280
+ },
281
+ };
282
+ }
283
+
284
+ /**
285
+ * A session's turn for one whole line, sent with its return in one go, as the
286
+ * mail nudge is: through the typing turn's gate, waited for `waitMs` at most,
287
+ * and then held beside any other line, so whole lines never wait on each
288
+ * other, only on typing one key at a time. Returns `{ release }`, or undefined
289
+ * when the gate did not come within `waitMs`. Every whole line the kit types
290
+ * takes it: the nudge, the list line, the skills reload and the grooming line (#482).
291
+ */
292
+ export function takeLineTurn(home, session, waitMs) {
293
+ const gate = turnOn(home, session, 'typing', waitMs);
294
+ if (gate === undefined) return undefined;
295
+ try {
296
+ return turnOn(home, session, 'lines', 0, 'SHARED');
297
+ } finally {
298
+ gate.release();
299
+ }
300
+ }
301
+
302
+ /**
303
+ * How long a kit path waits for a session's typing turn before it types
304
+ * nothing (#480, #482): a naming holds it for seconds.
305
+ */
306
+ export const TYPING_WAIT_MS = 5000;
307
+
308
+ /** What a kit path reports when the session's typing turn did not come within TYPING_WAIT_MS. */
309
+ export const TYPING_HELD = `the kit is typing into it, and it was still at it after ${TYPING_WAIT_MS / 1000} s, so nothing was typed`;
310
+
311
+ /**
312
+ * How long a typing turn waits for the lines already on their way: a nudge
313
+ * holds its line's turn while Orca waits up to 5 s to see it start a turn.
314
+ */
315
+ const LINES_WAIT_MS = 10_000;
316
+
317
+ /** A turn of the kit's own for one session, on a file beside its mailbox turn's. */
318
+ function turnOn(home, session, kind, waitMs, how) {
319
+ try {
320
+ return lockOn(mailboxLockFile(home, session).replace(/\.mailbox\.lock$/, `.${kind}.lock`), waitMs, how);
321
+ } catch (error) {
322
+ if (error.errcode === SQLITE_BUSY) return undefined;
323
+ throw error;
324
+ }
325
+ }
326
+
236
327
  /**
237
328
  * How long a step, a check or an `up` waits for a session's turn. Longer than a
238
329
  * step can hold it — three Orca calls of twenty seconds each at most — and short
package/src/clear.js CHANGED
@@ -14,12 +14,12 @@
14
14
  import { realpathSync, statSync } from 'node:fs';
15
15
  import { setTimeout as pause } from 'node:timers/promises';
16
16
 
17
- import { readBook } from './book.js';
17
+ import { readBook, takeTypingTurn, TYPING_HELD, TYPING_WAIT_MS } from './book.js';
18
18
  import { botDir, readBot } from './bot.js';
19
19
  import { claudeTranscript, codexRollout } from './conversations.js';
20
20
  import { harnessOf, ownCli, shellWord } from './launch.js';
21
21
  import { eachLine, firstLine } from './lines.js';
22
- import { findProject, orca, QUESTION_ON_SCREEN, questionIn, screenRows, tabs, tabToTypeInto } from './orca.js';
22
+ import { findProject, orca, QUESTION_ON_SCREEN, questionIn, screenRows, tabs, tabToTypeInto, TIMED_OUT } from './orca.js';
23
23
  import { botsNamed, sessionsOf, typeListLine } from './up.js';
24
24
 
25
25
  /** The harness's own command for each, as typed into its input line. Codex's clear is `/new` (tech notes, section 3). */
@@ -58,6 +58,13 @@ const CODEX_BARE_LINE = '0.160.0';
58
58
  /** How long the session is given to be idle before nothing is typed, and how long each look waits on it. */
59
59
  const IDLE_WAIT_MS = 30_000;
60
60
  const LOOK_MS = 2000;
61
+ /**
62
+ * How long each look between two characters waits on Orca's tui-idle, whose
63
+ * answer it does not use: an open slash menu keeps it from answering ok (live
64
+ * run 3 of #391). Short, so a naming holds the session's typing turn for
65
+ * seconds (#480).
66
+ */
67
+ const CHAR_LOOK_MS = 250;
61
68
  /** How long the screen is given to show the command typed, or Codex's question after `/new`. */
62
69
  const SCREEN_MS = 3000;
63
70
  /** How long the book is given to hold the new conversation, and a harness to record a compaction (the architect: up to 5 minutes). */
@@ -95,14 +102,20 @@ const AT_WORK = [/esc to interrupt/i, /^\s*\S\s+\S+…\s+\(\d/];
95
102
  export async function clearSession(bots, { bot, session }) {
96
103
  const it = sessionToType(bots, bot, session);
97
104
  const was = readBook(it.home).sessions[session]?.session ?? null;
98
- const { command, handle } = await enter(it, 'clear');
105
+ const { command, handle, typing } = await enter(it, 'clear');
99
106
 
100
- if (it.harness === 'codex') {
101
- await answerWhereToRun(it, handle, command);
102
- const line = await typeListLine(it.home, it.tabId, RECORD_LINE);
103
- if (!line.typed) {
104
- throw new Error(`${it.name}: ${command} went in, but the line that starts the new conversation's first turn was not typed (${line.why}), and Codex reports a new conversation only at its first turn. The book still holds ${was ?? 'no conversation'}. Look at its tab.`);
107
+ // Codex's question and the line after it are part of the clear, typed in
108
+ // the typing turn it took for the command.
109
+ try {
110
+ if (it.harness === 'codex') {
111
+ await answerWhereToRun(it, handle, command);
112
+ const line = await typeListLine(it.home, it.session, it.tabId, { line: RECORD_LINE, held: true });
113
+ if (!line.typed) {
114
+ throw new Error(`${it.name}: ${command} went in, but the line that starts the new conversation's first turn was not typed (${line.why}), and Codex reports a new conversation only at its first turn. The book still holds ${was ?? 'no conversation'}. Look at its tab.`);
115
+ }
105
116
  }
117
+ } finally {
118
+ typing.release();
106
119
  }
107
120
 
108
121
  const until = Date.now() + CLEAR_MS;
@@ -128,9 +141,10 @@ export async function compactSession(bots, { bot, session }) {
128
141
  // that ends while the kit waits for the session to be idle is the one
129
142
  // before it (review of PR #466). So only what is written after counts.
130
143
  let record;
131
- const { typedAt } = await enter(it, 'compact', () => {
144
+ const { typedAt, typing } = await enter(it, 'compact', () => {
132
145
  record = recordAt(it.harness, it.home, readBook(it.home).sessions[session]?.session ?? null);
133
146
  });
147
+ typing.release();
134
148
 
135
149
  const until = typedAt + COMPACT_MS;
136
150
  for (;;) {
@@ -150,7 +164,7 @@ export async function compactSession(bots, { bot, session }) {
150
164
  * The session as something to type into, once everything that refuses before
151
165
  * Orca is asked to type has been asked: `{ name, home, session, harness, tabId }`.
152
166
  */
153
- function sessionToType(bots, bot, session) {
167
+ export function sessionToType(bots, bot, session) {
154
168
  botsNamed(bots, bot);
155
169
  const home = realpathSync(botDir(bots, bot));
156
170
  const known = readBot(home, bot);
@@ -174,22 +188,58 @@ function sessionToType(bots, bot, session) {
174
188
  * Type the harness's command for `verb` into the session's tab and press
175
189
  * return, once it is idle and the screen shows the command typed and nothing
176
190
  * else. `before` runs just before the command is typed. Returns the command,
177
- * the handle it went into, and when it was typed.
191
+ * the handle it went into, when it was typed, and the session's typing turn
192
+ * (#482), taken once it is idle, as the naming takes it, and held for the
193
+ * caller to release.
178
194
  */
179
195
  async function enter(it, verb, before = () => {}) {
180
- const command = COMMANDS[verb][it.harness];
181
- const { handle, rows } = await idleTab(it);
196
+ let typing;
197
+ try {
198
+ const typed = await typeCommand(it, COMMANDS[verb][it.harness], {
199
+ before: () => {
200
+ typing = takeTypingTurn(it.home, it.session, TYPING_WAIT_MS);
201
+ if (typing === undefined) throw new Error(`${it.name}: ${TYPING_HELD}. Run this again in a moment.`);
202
+ before();
203
+ },
204
+ cannot: (wrong) => (verb === 'compact' && wrong.menu ? ` ${harnessName(it.harness)} here cannot compact: its menu does not offer ${COMMANDS[verb][it.harness]}.` : ''),
205
+ });
206
+ return { ...typed, typing };
207
+ } catch (error) {
208
+ typing?.release();
209
+ throw error;
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Type `command` into the session's tab, `it` as `sessionToType` gives it, and
215
+ * press return, once it is idle and `check` finds nothing wrong with the
216
+ * screen: by default the slash menu's check, which a command with words after
217
+ * it does not pass, since Codex closes its menu at the space (#480). `before`
218
+ * runs just before the command is typed; `cannot` adds to a refusal what a
219
+ * wrong screen means; past `deadline`, a time, what was typed is taken back
220
+ * and nothing is entered. Returns the command, the handle it went into, and
221
+ * when it was typed.
222
+ */
223
+ export async function typeCommand(it, command, { before = () => {}, check = menuWrong, cannot = () => '', deadline = Infinity } = {}) {
182
224
  // On Codex, what is typed cannot be read back once its menu is open, so
183
225
  // nothing may be in its input line before: no draft for the command to join.
184
- if (it.harness === 'codex') {
226
+ const emptyLine = (rows) => {
227
+ if (it.harness !== 'codex') return;
185
228
  const line = rows.findLast((row) => /^ *›/.test(row))?.trim();
186
229
  if (!CODEX_EMPTY.includes(line)) {
187
230
  throw new Error(`${it.name}: nothing was typed, because its input line is not empty: it reads "${line ?? 'nothing'}".${shownEnd(rows)}`);
188
231
  }
189
- }
232
+ };
233
+ emptyLine((await idleTab(it)).rows);
190
234
  const version = it.harness === 'codex' ? codexVersion(readBook(it.home).sessions[it.session]?.session) : undefined;
191
235
 
192
236
  before();
237
+ // What `before` waited for can have changed the session: one more look,
238
+ // with no wait, before the first key (#480 review).
239
+ const ready = lookAt(it);
240
+ if (ready.why !== undefined) throw new Error(`${it.name}: nothing was typed, because ${ready.why}.`);
241
+ emptyLine(ready.rows);
242
+ const { handle } = ready;
193
243
  const typedAt = Date.now();
194
244
  // One character a send, each after a look through the gate, so nothing goes
195
245
  // in as one burst and nothing goes in once something asks a question
@@ -197,31 +247,63 @@ async function enter(it, verb, before = () => {}) {
197
247
  // Between characters, and once more before the return, the look is the gate
198
248
  // and the harness's own at-work row, not Orca's tui-idle: an open slash menu
199
249
  // can stop it answering ok (live run 3).
250
+ // The characters Orca took, and so what is in the input line of ours.
200
251
  let typed = 0;
252
+ // Taken back, one backspace a character, in one send, so the input line is
253
+ // as it was. Whatever stops the typing takes them back, a send Orca refused
254
+ // or a look that threw included (#480 review); a take-back Orca refuses
255
+ // too leaves nothing more the kit can do.
256
+ const takeBack = () => {
257
+ const keys = '\x7f'.repeat(typed);
258
+ typed = 0;
259
+ if (keys === '') return;
260
+ try {
261
+ send(handle, keys);
262
+ } catch {
263
+ // Said by the error that stopped the typing.
264
+ }
265
+ };
201
266
  const lookAgain = async () => {
202
267
  await pause(CHAR_GAP_MS);
203
268
  const look = lookAt(it, { idle: false });
269
+ // Past the caller's deadline nothing more goes in: a hook stopped partway
270
+ // would leave the text in the input line (#480 review).
271
+ if (look.why === undefined && Date.now() > deadline) look.why = 'its time ran out';
204
272
  if (look.why === undefined) return;
205
- send(handle, '\x7f'.repeat(typed));
206
- throw new Error(`${it.name}: ${command} was being typed, and after ${command.slice(0, typed)} ${look.why}, so what was typed was taken back and nothing was entered.`);
273
+ const sofar = command.slice(0, typed);
274
+ takeBack();
275
+ throw new Error(`${it.name}: ${command} was being typed, and after ${sofar} ${look.why}, so what was typed was taken back and nothing was entered.`);
207
276
  };
208
- for (const char of command) {
209
- if (typed > 0) await lookAgain();
210
- send(handle, char);
211
- typed += 1;
212
- }
213
- await lookAgain();
214
- const wrong = await typedWrong(handle, it.harness, command, version);
215
- if (wrong !== undefined) {
216
- // Taken back, one backspace a character, so the input line is as it was.
217
- send(handle, '\x7f'.repeat(command.length));
218
- const cannot = verb === 'compact' && wrong.menu ? ` ${harnessName(it.harness)} here cannot compact: its menu does not offer ${command}.` : '';
219
- throw new Error(`${it.name}: ${command} was typed but not entered, and was taken back, because ${wrong.why}.${cannot}${shownEnd(wrong.rows)}`);
277
+ try {
278
+ for (const char of command) {
279
+ if (typed > 0) await lookAgain();
280
+ try {
281
+ send(handle, char);
282
+ } catch (error) {
283
+ // A send Orca did not answer in time may have gone in all the same.
284
+ // Counted only where the line was empty before the first key, so the
285
+ // one backspace too many can take nothing of the user's (#480 review).
286
+ if (error.code === TIMED_OUT && it.harness === 'codex') typed += 1;
287
+ throw error;
288
+ }
289
+ typed += 1;
290
+ }
291
+ await lookAgain();
292
+ let wrong = await typedWrong(handle, it.harness, command, version, check);
293
+ if (wrong === undefined && Date.now() > deadline) wrong = { why: 'its time ran out' };
294
+ if (wrong !== undefined) {
295
+ takeBack();
296
+ throw new Error(`${it.name}: ${command} was typed but not entered, and was taken back, because ${wrong.why}.${cannot(wrong)}${shownEnd(wrong.rows)}`);
297
+ }
298
+ // A return of its own, not `--enter`: Orca's gate can refuse a line sent
299
+ // with `--enter` while it names a reason, and on Codex a return inside the
300
+ // text lands in the draft (helpers in the system tests, #329).
301
+ send(handle, '\r');
302
+ typed = 0;
303
+ } catch (error) {
304
+ takeBack();
305
+ throw error;
220
306
  }
221
- // A return of its own, not `--enter`: Orca's gate can refuse a line sent
222
- // with `--enter` while it names a reason, and on Codex a return inside the
223
- // text lands in the draft (helpers in the system tests, #329).
224
- send(handle, '\r');
225
307
  return { command, handle, typedAt };
226
308
  }
227
309
 
@@ -235,6 +317,7 @@ async function idleTab(it) {
235
317
  const until = Date.now() + IDLE_WAIT_MS;
236
318
  for (;;) {
237
319
  const look = lookAt(it);
320
+ if (look.moved) throw new Error(`${it.name}: nothing was typed, because ${look.why}.`);
238
321
  let why = look.why;
239
322
  // The wait is a cutoff, not a count of looks: a yes that comes after it
240
323
  // is too late (as for LIST_LINE, review of PR #421).
@@ -248,11 +331,23 @@ async function idleTab(it) {
248
331
  /**
249
332
  * One look through the typing gate at the session's tab: `{ handle }` when
250
333
  * nothing on its screen asks a question and nothing says it is at work, and,
251
- * with `idle`, Orca's tui-idle answered ok; or `{ why }`, naming the signal.
334
+ * with `idle`, Orca's tui-idle answered ok; or `{ why }`, naming the signal,
335
+ * with `moved` when the book holds another tab for the session now, or, where
336
+ * `it.conversation` is given, another conversation.
252
337
  * Throws when the tab holds no harness.
253
338
  */
254
339
  function lookAt(it, { idle = true } = {}) {
255
- const found = tabToTypeInto(it.home, it.tabId, LOOK_MS);
340
+ // A restart can give the session a new tab while this waits or types, and
341
+ // a hook can outlive its Codex (#480): only the tab the book holds is the
342
+ // session's.
343
+ const entry = readBook(it.home).sessions[it.session];
344
+ if (entry?.tab !== it.tabId) return { moved: true, why: `the book no longer holds its tab ${it.tabId} for it, but ${entry?.tab ?? 'none'}` };
345
+ // And, for a caller typing for one conversation, a `/new` in the same tab
346
+ // gives the session another (#480 review).
347
+ if (it.conversation !== undefined && entry.session !== it.conversation) {
348
+ return { moved: true, why: `the book no longer holds the conversation ${it.conversation} for it, but ${entry.session ?? 'none'}` };
349
+ }
350
+ const found = tabToTypeInto(it.home, it.tabId, idle ? LOOK_MS : CHAR_LOOK_MS);
256
351
  if (found.blocked !== undefined) {
257
352
  return {
258
353
  why: found.blocked === QUESTION_ON_SCREEN
@@ -274,13 +369,14 @@ function lookAt(it, { idle = true } = {}) {
274
369
 
275
370
  /**
276
371
  * Why the screen does not show `command` typed and ready, or undefined when it
277
- * does: the input line, the lowest row the harness's pointer starts, reads the
278
- * pointer and the command and nothing else, and the slash menu's selected row
279
- * names the command. `menu` is set when the input line was right and the menu
280
- * was not. Read again for SCREEN_MS while it is not so: a screen
372
+ * does, as `check` reads it: for a slash command alone, the input line, the
373
+ * lowest row the harness's pointer starts, reads the pointer and the command
374
+ * and nothing else, and the slash menu's selected row names the command.
375
+ * `menu` is set when the input line was right and the menu was not. Read
376
+ * again for SCREEN_MS while it is not so: a screen
281
377
  * takes a moment to draw what was typed.
282
378
  */
283
- async function typedWrong(handle, harness, command, version) {
379
+ async function typedWrong(handle, harness, command, version, check) {
284
380
  const until = Date.now() + SCREEN_MS;
285
381
  for (;;) {
286
382
  const seen = screenRows(handle, READ_MS);
@@ -288,7 +384,7 @@ async function typedWrong(handle, harness, command, version) {
288
384
  // a question that shows on it stops it at once (review of c1e5ba4).
289
385
  const signal = seen.rows === undefined ? undefined : signalIn(seen.rows);
290
386
  if (signal !== undefined) return { why: signal, rows: seen.rows };
291
- const wrong = seen.rows === undefined ? { why: `its screen could not be read (${seen.unreadable})` } : menuWrong(seen.rows, harness, command, version);
387
+ const wrong = seen.rows === undefined ? { why: `its screen could not be read (${seen.unreadable})` } : check(seen.rows, harness, command, version);
292
388
  if (wrong === undefined || Date.now() >= until) return wrong && { ...wrong, rows: seen.rows };
293
389
  await pause(ASK_MS);
294
390
  }
@@ -449,7 +545,10 @@ function shownEnd(rows) {
449
545
  /** How many rows of the screen a refusal shows. */
450
546
  const SHOWN_ROWS = 12;
451
547
 
548
+ /** How long Orca is given to take one send: a send it never answers must not hold the caller (#480 review). */
549
+ const SEND_MS = 5000;
550
+
452
551
  /** Type `keys` into the tab as they are, with no return of Orca's. */
453
- const send = (handle, keys) => orca(['terminal', 'send', '--terminal', handle, '--text', keys]);
552
+ const send = (handle, keys) => orca(['terminal', 'send', '--terminal', handle, '--text', keys], { timeoutMs: SEND_MS });
454
553
 
455
554
  const harnessName = (harness) => (harness === 'claude' ? 'Claude Code' : 'Codex');
package/src/cli.js CHANGED
@@ -16,6 +16,7 @@ import { addSession, allowedNow, allowRules, botDir, changeBot, changeSession, c
16
16
  import { addCommand, groomCommand, grooming, upCommand } from './groom.js';
17
17
  import { checkHealth, orcaSettingFindings } from './health.js';
18
18
  import { initBots } from './init.js';
19
+ import { nameSession } from './name.js';
19
20
  import { APPROVALS, HARNESSES, ownCli, shellWord, workDirOf } from './launch.js';
20
21
  import { checkMail, decideLeftNudges, lookUp, noMailboxYet, sendMessage, sessionInTab } from './message.js';
21
22
  import { orcaCli, orcaTrouble, RELOAD_LINE, TERMINAL_ENV } from './orca.js';
@@ -30,7 +31,7 @@ import { readRoster } from './roster.js';
30
31
  import { buildAgents, buildRules, CODEX_CAP } from './rules.js';
31
32
  import { addSkill, buildSkills, linkSkills, removeSkill } from './skills.js';
32
33
  import { addSource, fetchSources } from './sources.js';
33
- import { listRoles, makeTemp, optionsLine, retireTemp, trustHooks } from './temp.js';
34
+ import { answerTemp, listRoles, makeTemp, optionsLine, retireTemp, trustHooks } from './temp.js';
34
35
  import { readUsage } from './usage.js';
35
36
  import { BOT_FATHER, bringUp, ownMailbox } from './up.js';
36
37
 
@@ -139,6 +140,12 @@ Usage:
139
140
  all and continue", and check the review went. It
140
141
  refuses anything else on that screen, and types
141
142
  nothing then.
143
+ obk temp answer --bots <path> --name <session>
144
+ Run in the maker's own tab: answer Claude Code's
145
+ "Teach auto mode" screen on a temporary session it
146
+ made with Not now, in a shape the kit knows, and
147
+ check the screen went. It refuses any other
148
+ screen, and types nothing then.
142
149
  obk rules build --bots <path> [--bot <bot>]
143
150
  Build every bot's AGENTS.md from its charter and
144
151
  the rule units it carries, or just the one you
@@ -274,6 +281,11 @@ Usage:
274
281
  nudges a send there left because it could not
275
282
  tell from inside Codex's sandbox, and types each
276
283
  one or says why not.
284
+ obk session name --bots <path> --bot <bot>
285
+ For the kit's own hook, not for typing: after a
286
+ Codex session's turn, it names the session's thread
287
+ <bot>.<session> with Codex's /rename, once the
288
+ session is idle, so its tab says which it is.
277
289
  obk session mailbox --bots <path> --bot <bot> --session <name>
278
290
  For the kit's own launch line, not for typing: run
279
291
  in the session's own tab, it gives the session its
@@ -317,11 +329,13 @@ const COMMANDS = {
317
329
  'session record': ['bots', 'bot'],
318
330
  'session sent': ['bots', 'bot'],
319
331
  'session nudge': ['bots', 'bot'],
332
+ 'session name': ['bots', 'bot'],
320
333
  'session mailbox': ['bots', 'bot', 'session'],
321
334
  'temp make': ['bots', 'name'],
322
335
  'temp roles': ['bots'],
323
336
  'temp retire': ['bots', 'name'],
324
337
  'temp trust-hooks': ['bots', 'name'],
338
+ 'temp answer': ['bots', 'name'],
325
339
  };
326
340
 
327
341
  /** What each flag is for, in the sentence a caller reads when it is missing. */
@@ -449,6 +463,7 @@ async function run(argv) {
449
463
  // there included, so it goes before anything that can complain (ADR 0032).
450
464
  if (command === SENT) return sent(path.resolve(values.bots));
451
465
  if (command === NUDGE) return nudgeLeft();
466
+ if (command === NAME) return nameThread(path.resolve(values.bots), values.bot);
452
467
 
453
468
  // One fleet, one identity, whatever spelling of its path was given (#164).
454
469
  const bots = command === 'init' ? path.resolve(values.bots) : sameFleet(path.resolve(values.bots));
@@ -517,6 +532,7 @@ const ANSWER_IT = [
517
532
  const RECORD = 'session record';
518
533
  const SENT = 'session sent';
519
534
  const NUDGE = 'session nudge';
535
+ const NAME = 'session name';
520
536
 
521
537
  /**
522
538
  * What the kit's send hook says after a native message: a warning the session
@@ -556,6 +572,21 @@ function nudgeLeft() {
556
572
  return 0;
557
573
  }
558
574
 
575
+ /**
576
+ * What the kit's Codex Stop hook does at a turn end: name the session's thread
577
+ * after its bot and session, once it is idle (#480). Silent, never a decision,
578
+ * and never anything but exit 0: what it could not type now waits for the next
579
+ * turn end.
580
+ */
581
+ async function nameThread(bots, bot) {
582
+ try {
583
+ await nameSession(bots, bot, JSON.parse(readFileSync(0, 'utf8')), process.env[TAB_ENV]);
584
+ } catch {
585
+ // Nothing: a hook does not disturb the session it runs in.
586
+ }
587
+ return 0;
588
+ }
589
+
559
590
  /**
560
591
  * What the kit's hook does with what the harness told it, and what it answers.
561
592
  *
@@ -768,6 +799,19 @@ const commands = {
768
799
  };
769
800
  },
770
801
 
802
+ async 'temp answer'(bots, values) {
803
+ const tab = callerTab(bots, true);
804
+ refuseWhenOrcaIsDown();
805
+ const answered = await answerTemp(bots, { tab, name: values.name });
806
+ return {
807
+ answer: { bots, ...answered },
808
+ lines: [
809
+ `answered ${answered.bot} ${answered.session}, a temporary session of ${answered.maker}'s: sent ${answered.sent} to its "Teach auto mode about your environment?" screen, and the screen has gone`,
810
+ `rule a maker's bot runs this under one permission rule, which the user approves: Bash(${shellWord(ownCli())} temp answer:*)`,
811
+ ],
812
+ };
813
+ },
814
+
771
815
  'bot create'(bots, values) {
772
816
  const made = createBot(bots, { name: values.name, harness: values.harness, charter: values.charter });
773
817
  // The bot's AGENTS.md is the rules build's, here as everywhere else, so
package/src/groom.js CHANGED
@@ -42,7 +42,7 @@ import { existsSync, mkdirSync, realpathSync, writeFileSync } from 'node:fs';
42
42
  import path from 'node:path';
43
43
 
44
44
  import { botDir, readBot, requireBotsFolder } from './bot.js';
45
- import { readBook } from './book.js';
45
+ import { readBook, takeLineTurn, TYPING_HELD, TYPING_WAIT_MS } from './book.js';
46
46
  import { claudeTranscript } from './conversations.js';
47
47
  import { harnessOf, ownCli, shellWord } from './launch.js';
48
48
  import { tabs, tabToTypeInto, typeIntoTab } from './orca.js';
@@ -189,21 +189,28 @@ function refuseRun(run, ask) {
189
189
  /**
190
190
  * Type `line` into the grooming tab, through the kit's one gate for typing into
191
191
  * a running session: nothing goes into a tab with something on screen waiting
192
- * for an answer, or with anything but Claude Code in front of it.
192
+ * for an answer, or with anything but Claude Code in front of it. In the
193
+ * session's turn for a line, so it never lands in what the kit is typing (#482).
193
194
  */
194
195
  function typeInto(bots, home, session, tab, line) {
195
196
  if (!session.up) {
196
197
  throw new Error(`${BOT_FATHER}'s ${GROOMING} session is not up, so nothing was typed. Bring it up with ${upCommand(bots)}, then run this again.`);
197
198
  }
198
- const found = tabToTypeInto(home, tab, LOOK_MS);
199
- if (found.blocked !== undefined) {
200
- throw new Error(`the ${GROOMING} tab is waiting for an answer (${found.blocked}), so nothing was typed. Answer it in the tab, then run this again.`);
201
- }
202
- if (found.unsure !== undefined) throw new Error(`the ${GROOMING} tab: ${found.unsure}.`);
203
- if (found.handle === undefined || found.agent !== 'claude') {
204
- throw new Error(`the ${GROOMING} tab has no Claude Code in front of it, so nothing was typed. Bring it back with ${restartCommand(bots)}, then run this again.`);
199
+ const turn = takeLineTurn(home, GROOMING, TYPING_WAIT_MS);
200
+ if (turn === undefined) throw new Error(`the ${GROOMING} tab: ${TYPING_HELD}. Run this again in a moment.`);
201
+ try {
202
+ const found = tabToTypeInto(home, tab, LOOK_MS);
203
+ if (found.blocked !== undefined) {
204
+ throw new Error(`the ${GROOMING} tab is waiting for an answer (${found.blocked}), so nothing was typed. Answer it in the tab, then run this again.`);
205
+ }
206
+ if (found.unsure !== undefined) throw new Error(`the ${GROOMING} tab: ${found.unsure}.`);
207
+ if (found.handle === undefined || found.agent !== 'claude') {
208
+ throw new Error(`the ${GROOMING} tab has no Claude Code in front of it, so nothing was typed. Bring it back with ${restartCommand(bots)}, then run this again.`);
209
+ }
210
+ typeIntoTab(found.handle, line);
211
+ } finally {
212
+ turn.release();
205
213
  }
206
- typeIntoTab(found.handle, line);
207
214
  }
208
215
 
209
216
  /**