@assuredloop/orca-bot-kit 0.23.0 → 0.24.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assuredloop/orca-bot-kit",
3
- "version": "0.23.0",
3
+ "version": "0.24.1",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
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';
@@ -274,6 +275,11 @@ Usage:
274
275
  nudges a send there left because it could not
275
276
  tell from inside Codex's sandbox, and types each
276
277
  one or says why not.
278
+ obk session name --bots <path> --bot <bot>
279
+ For the kit's own hook, not for typing: after a
280
+ Codex session's turn, it names the session's thread
281
+ <bot>.<session> with Codex's /rename, once the
282
+ session is idle, so its tab says which it is.
277
283
  obk session mailbox --bots <path> --bot <bot> --session <name>
278
284
  For the kit's own launch line, not for typing: run
279
285
  in the session's own tab, it gives the session its
@@ -317,6 +323,7 @@ const COMMANDS = {
317
323
  'session record': ['bots', 'bot'],
318
324
  'session sent': ['bots', 'bot'],
319
325
  'session nudge': ['bots', 'bot'],
326
+ 'session name': ['bots', 'bot'],
320
327
  'session mailbox': ['bots', 'bot', 'session'],
321
328
  'temp make': ['bots', 'name'],
322
329
  'temp roles': ['bots'],
@@ -449,6 +456,7 @@ async function run(argv) {
449
456
  // there included, so it goes before anything that can complain (ADR 0032).
450
457
  if (command === SENT) return sent(path.resolve(values.bots));
451
458
  if (command === NUDGE) return nudgeLeft();
459
+ if (command === NAME) return nameThread(path.resolve(values.bots), values.bot);
452
460
 
453
461
  // One fleet, one identity, whatever spelling of its path was given (#164).
454
462
  const bots = command === 'init' ? path.resolve(values.bots) : sameFleet(path.resolve(values.bots));
@@ -517,6 +525,7 @@ const ANSWER_IT = [
517
525
  const RECORD = 'session record';
518
526
  const SENT = 'session sent';
519
527
  const NUDGE = 'session nudge';
528
+ const NAME = 'session name';
520
529
 
521
530
  /**
522
531
  * What the kit's send hook says after a native message: a warning the session
@@ -556,6 +565,21 @@ function nudgeLeft() {
556
565
  return 0;
557
566
  }
558
567
 
568
+ /**
569
+ * What the kit's Codex Stop hook does at a turn end: name the session's thread
570
+ * after its bot and session, once it is idle (#480). Silent, never a decision,
571
+ * and never anything but exit 0: what it could not type now waits for the next
572
+ * turn end.
573
+ */
574
+ async function nameThread(bots, bot) {
575
+ try {
576
+ await nameSession(bots, bot, JSON.parse(readFileSync(0, 'utf8')), process.env[TAB_ENV]);
577
+ } catch {
578
+ // Nothing: a hook does not disturb the session it runs in.
579
+ }
580
+ return 0;
581
+ }
582
+
559
583
  /**
560
584
  * What the kit's hook does with what the harness told it, and what it answers.
561
585
  *
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
  /**
package/src/hooks.js CHANGED
@@ -69,6 +69,21 @@ const NUDGE_TIMEOUT = 30;
69
69
  export const nudgeCommand = (bots, bot, cli = ownCli()) =>
70
70
  `${shellWord(cli)} session nudge --bots ${shellWord(bots)} --bot ${shellWord(bot)} 2>/dev/null || true`;
71
71
 
72
+ /**
73
+ * Codex's third kit hook: at each turn end, the session's thread is named after
74
+ * its bot and session, so its tab says which it is (#480). Async, so the turn
75
+ * ends without waiting on it: it waits for the session to be idle, up to 30 s,
76
+ * then types and confirms. A naming took 73 s live, each character waiting on
77
+ * a look through Orca's gate, and a hook stopped partway would leave half the
78
+ * command in the input line, so the timeout leaves room to spare.
79
+ */
80
+ const NAME_EVENT = 'Stop';
81
+ const NAME_TIMEOUT = 300;
82
+
83
+ /** What the naming hook runs, by the kit's own path, as `hookCommand` does. */
84
+ export const nameCommand = (bots, bot, cli = ownCli()) =>
85
+ `${shellWord(cli)} session name --bots ${shellWord(bots)} --bot ${shellWord(bot)} 2>/dev/null || true`;
86
+
72
87
  /**
73
88
  * Make sure the bot at `home` has the kit's SessionStart hook for `harness`.
74
89
  * Returns the file it wrote, relative to the bot home, or undefined when the
@@ -91,7 +106,7 @@ export function installHook(home, harness, { bots, bot }) {
91
106
  // longer asks about is taken out, and a hook of the user's beside the kit's
92
107
  // stays where they put it.
93
108
  const started = withKitHook(settings.hooks, file, mine);
94
- const wanted = { ...settings, hooks: withToolHook(started, toolHookOf(harness, bots, bot)) };
109
+ const wanted = { ...settings, hooks: toolHooksOf(harness, bots, bot).reduce(withToolHook, started) };
95
110
 
96
111
  // Compared as documents, not as text: how the user laid their file out is
97
112
  // theirs, and a run that changes nothing writes nothing.
@@ -138,26 +153,29 @@ export function hookTrouble(home, harness, { bots, bot }) {
138
153
  }
139
154
  const program = running ?? ownCli();
140
155
  wanted = { ...settings, hooks: withKitHook(settings.hooks, file, { type: 'command', command: hookCommand(bots, bot, program), timeout: TIMEOUT }) };
141
- // The harness's second hook is judged on its own: the session hook may be
142
- // right while the warning after a native message (ADR 0032), or the nudge
143
- // a Codex sender left (ADR 0034), is missing.
156
+ // The harness's other hooks are each judged on their own: the session hook
157
+ // may be right while the warning after a native message (ADR 0032), the
158
+ // nudge a Codex sender left (ADR 0034), or the naming of a Codex thread
159
+ // (#480) is missing.
144
160
  if (isDeepStrictEqual(settings, wanted)) {
145
- const tool = toolHookOf(harness, bots, bot);
146
- const toolRunning = programIn(settings, tool.pattern);
147
- // One that runs a kit no longer there is as quiet as a missing one:
148
- // `|| true` hides it (#451 review).
149
- if (toolRunning !== undefined && toolRunning !== BARE && !existsSync(toolRunning)) {
150
- return {
151
- where: file,
152
- says: `${file} holds the kit's ${tool.event} hook for ${tool.matcher}, but it runs ${toolRunning}, which is not there any more, so ${tool.missing}. obk up puts back one that runs the kit you have.`,
153
- };
154
- }
155
- const withTool = { ...settings, hooks: withToolHook(settings.hooks, toolHookOf(harness, bots, bot, toolRunning ?? program)) };
156
- if (!isDeepStrictEqual(settings, withTool)) {
157
- return {
158
- where: file,
159
- says: `${file} does not hold the kit's ${tool.event} hook for ${tool.matcher}, without which ${tool.missing}. obk up puts it back.`,
160
- };
161
+ for (const tool of toolHooksOf(harness, bots, bot)) {
162
+ const toolRunning = programIn(settings, tool.pattern);
163
+ const what = tool.matcher === undefined ? `${tool.event} hook` : `${tool.event} hook for ${tool.matcher}`;
164
+ // One that runs a kit no longer there is as quiet as a missing one:
165
+ // `|| true` hides it (#451 review).
166
+ if (toolRunning !== undefined && toolRunning !== BARE && !existsSync(toolRunning)) {
167
+ return {
168
+ where: file,
169
+ says: `${file} holds the kit's ${what}, but it runs ${toolRunning}, which is not there any more, so ${tool.missing}. obk up puts back one that runs the kit you have.`,
170
+ };
171
+ }
172
+ const withTool = { ...settings, hooks: withToolHook(settings.hooks, toolHookOf(tool.kind, bots, bot, toolRunning ?? program)) };
173
+ if (!isDeepStrictEqual(settings, withTool)) {
174
+ return {
175
+ where: file,
176
+ says: `${file} does not hold the kit's ${what}, without which ${tool.missing}. obk up puts it back.`,
177
+ };
178
+ }
161
179
  }
162
180
  }
163
181
  } catch (error) {
@@ -230,15 +248,29 @@ function withKitHook(hooks, file, mine) {
230
248
  return { ...events, [EVENT]: [...(events[EVENT] ?? []), { hooks: [mine] }] };
231
249
  }
232
250
 
251
+ /** Each harness's kit hooks beside its session hook, as `toolHookOf` gives each. */
252
+ const toolHooksOf = (harness, bots, bot, cli = ownCli()) =>
253
+ (harness === 'claude' ? ['sent'] : ['nudge', 'name']).map((kind) => toolHookOf(kind, bots, bot, cli));
254
+
233
255
  /**
234
- * Each harness's second kit hook, after one of its tools: Claude Code's after a
235
- * native `SendMessage` (ADR 0032), Codex's after its shell tool (ADR 0034).
236
- * The entry, where it goes, how the kit knows its own, and what is lost
237
- * without it.
256
+ * One of the kit's hooks beside its session hook: Claude Code's after a native
257
+ * `SendMessage` (`sent`, ADR 0032), Codex's after its shell tool (`nudge`,
258
+ * ADR 0034), and Codex's at a turn end (`name`, #480). The entry, where it
259
+ * goes, how the kit knows its own, and what is lost without it.
238
260
  */
239
- function toolHookOf(harness, bots, bot, cli = ownCli()) {
240
- if (harness === 'claude') {
261
+ function toolHookOf(kind, bots, bot, cli = ownCli()) {
262
+ if (kind === 'name') {
263
+ return {
264
+ kind,
265
+ event: NAME_EVENT,
266
+ pattern: KIT_NAME,
267
+ mine: { type: 'command', command: nameCommand(bots, bot, cli), timeout: NAME_TIMEOUT, async: true },
268
+ missing: "a Codex session's thread is never named after its bot and session, and its tab shows a topic Codex made up instead (#480)",
269
+ };
270
+ }
271
+ if (kind === 'sent') {
241
272
  return {
273
+ kind,
242
274
  event: SENT_EVENT,
243
275
  matcher: SENT_MATCHER,
244
276
  pattern: KIT_SENT,
@@ -247,6 +279,7 @@ function toolHookOf(harness, bots, bot, cli = ownCli()) {
247
279
  };
248
280
  }
249
281
  return {
282
+ kind,
250
283
  event: NUDGE_EVENT,
251
284
  matcher: NUDGE_MATCHER,
252
285
  pattern: KIT_NUDGE,
@@ -257,10 +290,11 @@ function toolHookOf(harness, bots, bot, cli = ownCli()) {
257
290
 
258
291
  /**
259
292
  * The hooks with the kit's tool hook in a group of its event matched to its
260
- * tool, and everything else as the user left it, as `withKitHook` keeps the
261
- * session hook: one entry of the kit's, written in place where it already sits
262
- * in such a group, taken out of anywhere else, and added in a group of its own
263
- * when there is none. A group the kit empties goes.
293
+ * tool (or with no matcher, for one that has none), and everything else as the
294
+ * user left it, as `withKitHook` keeps the session hook: one entry of the
295
+ * kit's, written in place where it already sits in such a group, taken out of
296
+ * anywhere else, and added in a group of its own when there is none. A group
297
+ * the kit empties goes.
264
298
  */
265
299
  function withToolHook(hooks, { event: wantedEvent, matcher, pattern, mine }) {
266
300
  let placed = false;
@@ -279,7 +313,8 @@ function withToolHook(hooks, { event: wantedEvent, matcher, pattern, mine }) {
279
313
  return kept.length === 0 ? [] : [[event, kept]];
280
314
  }));
281
315
  if (placed) return events;
282
- return { ...events, [wantedEvent]: [...(events[wantedEvent] ?? []), { matcher, hooks: [mine] }] };
316
+ const group = matcher === undefined ? { hooks: [mine] } : { matcher, hooks: [mine] };
317
+ return { ...events, [wantedEvent]: [...(events[wantedEvent] ?? []), group] };
283
318
  }
284
319
 
285
320
  /** The program a command of the kit's of this pattern runs, unquoted, or undefined for any other command. */
@@ -320,6 +355,7 @@ const WORD = String.raw`(?:[A-Za-z0-9,._+:@%/=-]+|'(?:[^']|'\\'')*')`;
320
355
  const KIT_HOOK = new RegExp(String.raw`^(${WORD}) session record --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
321
356
  const KIT_SENT = new RegExp(String.raw`^(${WORD}) session sent --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
322
357
  const KIT_NUDGE = new RegExp(String.raw`^(${WORD}) session nudge --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
358
+ const KIT_NAME = new RegExp(String.raw`^(${WORD}) session name --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
323
359
 
324
360
  /** The program a bot made before the kit named itself by path runs. */
325
361
  const BARE = 'obk';
package/src/message.js CHANGED
@@ -19,7 +19,7 @@ import { mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync,
19
19
  import os from 'node:os';
20
20
  import path from 'node:path';
21
21
 
22
- import { MAILBOX_WAIT_MS, readBook, takeMailboxTurn } from './book.js';
22
+ import { MAILBOX_WAIT_MS, readBook, takeLineTurn, takeMailboxTurn, TYPING_HELD, TYPING_WAIT_MS } from './book.js';
23
23
  import { botDir, botNames, readBot } from './bot.js';
24
24
  import { harnessOf, isAddressOf, ownCli, reachesMail, SHELL_ENV, shellWord } from './launch.js';
25
25
  import { ackMailbox, coordinatorOf, postMessage, readMailbox, tabs, tabToTypeInto, TERMINAL_ENV, TIMED_OUT, typeIntoTab, useMailbox } from './orca.js';
@@ -456,6 +456,18 @@ const stamp = () => new Date().toISOString().replaceAll(':', '-').replace('.', '
456
456
  function nudge(to, from, subject, tab) {
457
457
  if (to.tab === undefined) return { nudged: false };
458
458
 
459
+ // A line typed while the kit types a command one key at a time would land
460
+ // in it and send it with its own return (#480): so the receiver's turn for
461
+ // a line first, for a bounded time, and the mail waits in its mailbox if not.
462
+ let turn;
463
+ try {
464
+ turn = takeLineTurn(to.home, to.session, TYPING_WAIT_MS);
465
+ } catch (error) {
466
+ return { nudged: false, nudgeTrouble: error.message };
467
+ }
468
+ if (turn === undefined) {
469
+ return { nudged: false, nudgeTrouble: TYPING_HELD };
470
+ }
459
471
  try {
460
472
  const found = lookAt(to);
461
473
  if (found.blocked !== undefined) return { nudged: false, blocked: found.blocked };
@@ -483,6 +495,8 @@ function nudge(to, from, subject, tab) {
483
495
  // silent `false` would read as "the session is not up", which is a
484
496
  // different thing from "Orca would not say".
485
497
  return { nudged: false, nudgeTrouble: error.message };
498
+ } finally {
499
+ turn.release();
486
500
  }
487
501
  }
488
502
 
package/src/name.js ADDED
@@ -0,0 +1,138 @@
1
+ // `obk session name`: the kit names a Codex session's thread `<bot>.<session>`
2
+ // (#480), so the tab, which Codex titles with the thread's name after every
3
+ // turn, says which bot and session it is. A Claude session has its name from
4
+ // the kit's `-n` already.
5
+ //
6
+ // Codex 0.160.0 has no launch-time way to name a thread, so this types Codex's
7
+ // own `/rename` (the architect's ruling on #480). It runs from the kit's async
8
+ // Stop hook (src/hooks.js) at each turn end, never inside a turn: a fresh
9
+ // start's first turn is its start prompt, which nothing may cut off. It types
10
+ // only through the path `obk session clear` takes (src/clear.js, #391), and
11
+ // what is not safe to type now waits for the next turn end.
12
+
13
+ import { readFileSync, realpathSync } from 'node:fs';
14
+ import { homedir } from 'node:os';
15
+ import path from 'node:path';
16
+ import { setTimeout as pause } from 'node:timers/promises';
17
+
18
+ import { readBook, takeNameTurn, takeTypingTurn } from './book.js';
19
+ import { botDir, readBot } from './bot.js';
20
+ import { sessionToType, typeCommand } from './clear.js';
21
+ import { harnessOf } from './launch.js';
22
+ import { sessionsOf } from './up.js';
23
+
24
+ /**
25
+ * Where Codex keeps its threads' names: a line `{ id, thread_name, updated_at }`
26
+ * a name, the file only growing and the newest line for an id winning
27
+ * (codex-rs/rollout/src/session_index.rs, rust-v0.160.0).
28
+ */
29
+ const sessionIndex = () => path.join(homedir(), '.codex', 'session_index.jsonl');
30
+
31
+ /**
32
+ * When a naming stops typing, counted from its start: well inside the hook's
33
+ * 300 s (src/hooks.js), at which Codex kills the hook's whole process group
34
+ * (codex-rs/hooks/src/engine/command_runner.rs), so that what was typed is
35
+ * taken back first.
36
+ */
37
+ const DEADLINE_MS = 240_000;
38
+
39
+ /** How long Codex is given to write the name down after the return. */
40
+ const CONFIRM_MS = 10_000;
41
+ const ASK_MS = 500;
42
+
43
+ /**
44
+ * Name the thread that just ended a turn in the tab `tabId`, as Codex's Stop
45
+ * hook `said` it, when that tab is a Codex session of `bot` and its thread does
46
+ * not have the name yet. Returns whether Codex's record names it afterwards,
47
+ * or undefined when there was nothing to do. Throws when it could not type.
48
+ */
49
+ export async function nameSession(bots, bot, said, tabId) {
50
+ const deadline = Date.now() + DEADLINE_MS;
51
+ if (said?.hook_event_name !== 'Stop') return undefined;
52
+ if (typeof tabId !== 'string' || tabId === '') return undefined;
53
+
54
+ const home = realpathSync(botDir(bots, bot));
55
+ const [session, entry] = Object.entries(readBook(home).sessions).find(([, one]) => one?.tab === tabId) ?? [];
56
+ if (session === undefined) return undefined;
57
+ const known = readBot(home, bot);
58
+ const [settings] = sessionsOf(known, session);
59
+ if (harnessOf(settings, known.harness) !== 'codex') return undefined;
60
+ // Only the thread the book says this tab is running. A Codex that Orca
61
+ // brought back by itself runs its hooks in Codex's shared background server,
62
+ // under another session's tab (#408), and that tab shows another thread.
63
+ const thread = said.session_id;
64
+ if (typeof thread !== 'string' || thread !== entry.session) return undefined;
65
+
66
+ const name = `${bot}.${session}`;
67
+ if (nameOf(thread) === name) return undefined;
68
+
69
+ const turn = takeNameTurn(home, session);
70
+ if (turn === undefined) return undefined;
71
+ let typing;
72
+ try {
73
+ // Another naming may have finished while this one waited for its turn.
74
+ if (nameOf(thread) === name) return undefined;
75
+ // Only into the tab the hook ran in: one whose Codex was killed hard can
76
+ // leave its hook running (Codex starts each hook in a session of its own,
77
+ // codex-rs/hooks/src/engine/command_runner.rs), and the tab the book holds
78
+ // now shows another process.
79
+ const it = { ...sessionToType(bots, bot, session), conversation: thread };
80
+ if (it.tabId !== tabId) return undefined;
81
+ // The session's typing turn, taken once it is idle and not before, so a
82
+ // mail nudge waits seconds for it and not the whole wait for idle. Held by
83
+ // anything else: nothing is typed, and the next turn end tries again.
84
+ const before = () => {
85
+ typing = takeTypingTurn(home, session, 0);
86
+ if (typing === undefined) throw new Error(`${it.name}: nothing was typed, because the kit is typing into its tab already.`);
87
+ };
88
+ try {
89
+ await typeCommand(it, `/rename ${name}`, { check: renameWrong, deadline, before });
90
+ } finally {
91
+ typing?.release();
92
+ }
93
+ const until = Date.now() + CONFIRM_MS;
94
+ while (nameOf(thread) !== name && Date.now() < until) await pause(ASK_MS);
95
+ return nameOf(thread) === name;
96
+ } finally {
97
+ turn.release();
98
+ }
99
+ }
100
+
101
+ /** The name Codex's record gives `thread` now, or undefined. */
102
+ function nameOf(thread) {
103
+ let text;
104
+ try {
105
+ text = readFileSync(sessionIndex(), 'utf8');
106
+ } catch {
107
+ return undefined;
108
+ }
109
+ let name;
110
+ for (const line of text.split('\n')) {
111
+ let entry;
112
+ try {
113
+ entry = JSON.parse(line);
114
+ } catch {
115
+ continue;
116
+ }
117
+ if (entry?.id === thread && typeof entry.thread_name === 'string') name = entry.thread_name;
118
+ }
119
+ return name;
120
+ }
121
+
122
+ /**
123
+ * Why the screen does not show `command` typed and ready, or undefined when it
124
+ * does: the input line, the lowest row Codex's pointer starts, reads the
125
+ * pointer and the command exactly, and no row of its slash menu stands above
126
+ * it. With words after the command Codex has closed its menu (chat_composer.rs
127
+ * `sync_command_popup`, rust-v0.160.0), so a menu still open means the line is
128
+ * not what it seems.
129
+ */
130
+ function renameWrong(rows, _harness, command) {
131
+ const at = rows.findLastIndex((row) => /^ *›/.test(row));
132
+ if (at < 0) return { why: 'its screen shows no input line' };
133
+ const line = rows[at].replaceAll(' ', ' ').trim();
134
+ if (line !== `› ${command}`) return { why: `its input line reads "${line}"` };
135
+ const above = rows.slice(0, at).findLast((row) => row.trim() !== '');
136
+ if (above !== undefined && /^ *(?:› +)?\/\S/.test(above)) return { why: `a menu of commands stands above its input line: "${above.trim()}"` };
137
+ return undefined;
138
+ }
package/src/skills.js CHANGED
@@ -23,7 +23,7 @@ import { fileURLToPath } from 'node:url';
23
23
 
24
24
  import { parse, parseDocument, stringify } from 'yaml';
25
25
 
26
- import { bookFile, readBook } from './book.js';
26
+ import { bookFile, readBook, takeLineTurn, TYPING_HELD, TYPING_WAIT_MS } from './book.js';
27
27
  import { botDir, botNames, changesExactly, leadsOutside, readBot, YAML_OUT } from './bot.js';
28
28
  import { harnessOf, ownCli, shellWord } from './launch.js';
29
29
  import { orcaCli, orcaTrouble, tabToTypeInto, typeIntoTab } from './orca.js';
@@ -473,7 +473,14 @@ function tellSessions(home, bot, linked) {
473
473
  const tab = typeof book[session.name]?.tab === 'string' ? book[session.name].tab : undefined;
474
474
  if (tab === undefined) return { ...told, state: 'not-up' };
475
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` };
476
+ let turn;
476
477
  try {
478
+ // The session's turn for a line (#482), for the look and its line. Codex
479
+ // is typed nothing here, so it waits for none.
480
+ if (harness !== 'codex') {
481
+ turn = takeLineTurn(real, session.name, TYPING_WAIT_MS);
482
+ if (turn === undefined) return { ...told, state: 'unknown', trouble: TYPING_HELD };
483
+ }
477
484
  const found = tabToTypeInto(real, tab, LOOK_MS);
478
485
  if (found.blocked !== undefined) return { ...told, state: 'blocked', blocked: found.blocked };
479
486
  if (found.unsure !== undefined) return { ...told, state: 'unknown', trouble: found.unsure };
@@ -495,6 +502,8 @@ function tellSessions(home, bot, linked) {
495
502
  // The links are made whatever Orca says, so this is reported, not
496
503
  // thrown: the build did what it was asked, and the session was not told.
497
504
  return { ...told, state: 'unknown', trouble: error.message };
505
+ } finally {
506
+ turn?.release();
498
507
  }
499
508
  });
500
509
  }
package/src/temp.js CHANGED
@@ -28,7 +28,7 @@ import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
28
28
  import path from 'node:path';
29
29
  import { setTimeout as pause } from 'node:timers/promises';
30
30
 
31
- import { readBook, updateBook } from './book.js';
31
+ import { readBook, takeLineTurn, TYPING_HELD, TYPING_WAIT_MS, updateBook } from './book.js';
32
32
  import { addSession, dropSession, NAME, readBot, tempRoles } from './bot.js';
33
33
  import { DEFAULT_APPROVAL, harnessOf, isShortPrompt, ownCli, shellWord, startPrompt, workDirOf } from './launch.js';
34
34
  import { sessionInTab } from './message.js';
@@ -353,16 +353,23 @@ export async function trustHooks(bots, { tab, name }) {
353
353
  if (handle === undefined) {
354
354
  throw new Error(`${run} has no tab open in Orca, so there is no review of its to answer. ${nothing}`);
355
355
  }
356
- const seen = screenRows(handle);
357
- if (seen.rows === undefined) {
358
- throw new Error(`${run}'s screen could not be read (${seen.unreadable}), so the kit cannot tell whether its hooks review is there. ${nothing}`);
359
- }
360
- const keys = keysToTrustAll(seen.rows);
361
- if (keys === undefined) {
362
- throw new Error(`${run}'s screen shows no "${HOOKS_REVIEW}" with its choices, so there is nothing for this to answer. It shows: ${shown(seen.rows)}. ${nothing}`);
356
+ // The session's turn for a line (#482), for the look and the keys.
357
+ const turn = takeLineTurn(caller.home, name, TYPING_WAIT_MS);
358
+ if (turn === undefined) throw new Error(`${run}: ${TYPING_HELD}.`);
359
+ try {
360
+ const seen = screenRows(handle);
361
+ if (seen.rows === undefined) {
362
+ throw new Error(`${run}'s screen could not be read (${seen.unreadable}), so the kit cannot tell whether its hooks review is there. ${nothing}`);
363
+ }
364
+ const keys = keysToTrustAll(seen.rows);
365
+ if (keys === undefined) {
366
+ throw new Error(`${run}'s screen shows no "${HOOKS_REVIEW}" with its choices, so there is nothing for this to answer. It shows: ${shown(seen.rows)}. ${nothing}`);
367
+ }
368
+ // The return is inside the text: `--enter` would be a second key.
369
+ orca(['terminal', 'send', '--terminal', handle, '--text', keys]);
370
+ } finally {
371
+ turn.release();
363
372
  }
364
- // The return is inside the text: `--enter` would be a second key.
365
- orca(['terminal', 'send', '--terminal', handle, '--text', keys]);
366
373
  for (let waited = 0; ; waited += 500) {
367
374
  const after = screenRows(handle);
368
375
  if (after.rows !== undefined && !after.rows.some((row) => row.includes(HOOKS_REVIEW))) break;
package/src/up.js CHANGED
@@ -8,7 +8,7 @@ import { existsSync, mkdirSync, realpathSync, writeFileSync } from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import { setTimeout as pause } from 'node:timers/promises';
10
10
 
11
- import { forgetClaimed, forgetSession, MAILBOX_WAIT_MS, readBook, sessionIdsIn, tabIdsIn, takeMailboxTurn, updateBook, withUnclaimed } from './book.js';
11
+ import { forgetClaimed, forgetSession, MAILBOX_WAIT_MS, readBook, sessionIdsIn, tabIdsIn, takeLineTurn, takeMailboxTurn, TYPING_HELD, TYPING_WAIT_MS, updateBook, withUnclaimed } from './book.js';
12
12
  import { botDir, botNames, displayName, projectName, readBot } from './bot.js';
13
13
  import { conversationsIn, hasConversation, heldAsUserTurn, transcriptsIn } from './conversations.js';
14
14
  import { installHook } from './hooks.js';
@@ -374,7 +374,7 @@ async function bringUpSession(bots, home, live, session, bot, title) {
374
374
  // 0.157.1 (#226). So it is given one line, through the same gate as a mail
375
375
  // notice, which the owner allowed. A fresh start's first turn is its prompt.
376
376
  const list = harness === 'codex' && launch.resume !== undefined && tui.running === true
377
- ? await typeListLine(home, made.tabId)
377
+ ? await typeListLine(home, session.name, made.tabId)
378
378
  : undefined;
379
379
 
380
380
  return entry(made, {
@@ -408,43 +408,65 @@ const LIST_LOOK_MS = 2000;
408
408
  const LIST_ASK_MS = 500;
409
409
 
410
410
  /**
411
- * Type `line`, LIST_LINE unless told otherwise, into the tab `tabId` once the
412
- * gate every typed line goes through lets it: `{ typed: true }`, or
413
- * `{ typed: false, why }` with the gate's last reason once LIST_WAIT_MS is up.
414
- * Nothing goes into a question, a form or a menu (#329, #416), nor into a tab
415
- * the kit cannot tell about.
411
+ * Type `line`, LIST_LINE unless told otherwise, into the tab `tabId` of
412
+ * `session` once the gate every typed line goes through lets it:
413
+ * `{ typed: true }`, or `{ typed: false, why }` with the gate's last reason
414
+ * once LIST_WAIT_MS is up. Nothing goes into a question, a form or a menu
415
+ * (#329, #416), nor into a tab the kit cannot tell about. Each look and its
416
+ * line are taken in the session's turn for a line (#482), unless the caller
417
+ * says it holds the session's typing turn already (`held`).
416
418
  */
417
- export async function typeListLine(home, tabId, line = LIST_LINE) {
419
+ export async function typeListLine(home, session, tabId, { line = LIST_LINE, held = false } = {}) {
418
420
  const until = Date.now() + LIST_WAIT_MS;
419
421
  for (;;) {
420
- let found;
421
- try {
422
- // Each look waits no longer than what is left of the wait.
423
- found = tabToTypeInto(home, tabId, Math.max(1, Math.min(LIST_LOOK_MS, until - Date.now())));
424
- } catch (error) {
425
- return { typed: false, why: `Orca would not say whether it may be typed into: ${error.message}` };
426
- }
427
- // A yes that comes after the wait is too late: the wait is a cutoff, not a
428
- // count of looks (review of PR #421).
429
- if (found.handle !== undefined && Date.now() > until) {
430
- return { typed: false, why: `the ${LIST_WAIT_MS / 1000} s wait for it ran out before the kit could tell it may be typed into` };
431
- }
432
- if (found.handle !== undefined) {
422
+ let turn;
423
+ if (!held) {
433
424
  try {
434
- typeIntoTab(found.handle, line);
435
- return { typed: true };
425
+ turn = takeLineTurn(home, session, TYPING_WAIT_MS);
436
426
  } catch (error) {
437
- return { typed: false, why: `Orca refused the line: ${error.message}` };
427
+ return { typed: false, why: error.message };
438
428
  }
429
+ if (turn === undefined) return { typed: false, why: TYPING_HELD };
439
430
  }
440
- if (Date.now() >= until) {
441
- const last = found.blocked !== undefined ? `it is waiting on ${found.blocked}` : found.unsure ?? 'no harness is running in it';
442
- return { typed: false, why: `the ${LIST_WAIT_MS / 1000} s wait for it ran out: ${last}` };
431
+ try {
432
+ const done = lookAndType(home, tabId, line, until);
433
+ if (done !== undefined) return done;
434
+ } finally {
435
+ turn?.release();
443
436
  }
444
437
  await pause(LIST_ASK_MS);
445
438
  }
446
439
  }
447
440
 
441
+ /** One look of typeListLine's, and its line when the gate lets it: what it returns, or undefined to look again. */
442
+ function lookAndType(home, tabId, line, until) {
443
+ let found;
444
+ try {
445
+ // Each look waits no longer than what is left of the wait.
446
+ found = tabToTypeInto(home, tabId, Math.max(1, Math.min(LIST_LOOK_MS, until - Date.now())));
447
+ } catch (error) {
448
+ return { typed: false, why: `Orca would not say whether it may be typed into: ${error.message}` };
449
+ }
450
+ // A yes that comes after the wait is too late: the wait is a cutoff, not a
451
+ // count of looks (review of PR #421).
452
+ if (found.handle !== undefined && Date.now() > until) {
453
+ return { typed: false, why: `the ${LIST_WAIT_MS / 1000} s wait for it ran out before the kit could tell it may be typed into` };
454
+ }
455
+ if (found.handle !== undefined) {
456
+ try {
457
+ typeIntoTab(found.handle, line);
458
+ return { typed: true };
459
+ } catch (error) {
460
+ return { typed: false, why: `Orca refused the line: ${error.message}` };
461
+ }
462
+ }
463
+ if (Date.now() >= until) {
464
+ const last = found.blocked !== undefined ? `it is waiting on ${found.blocked}` : found.unsure ?? 'no harness is running in it';
465
+ return { typed: false, why: `the ${LIST_WAIT_MS / 1000} s wait for it ran out: ${last}` };
466
+ }
467
+ return undefined;
468
+ }
469
+
448
470
  /**
449
471
  * The line that starts this session: resuming `resume`, or, with none, a fresh
450
472
  * start carrying its duty. Returns `{ resume, prompt, promptFile, command, address }`.