@assuredloop/orca-bot-kit 0.34.0 → 0.35.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/README.md CHANGED
@@ -394,6 +394,15 @@ address that is none of your bots folder's sessions, the hook tells it so, and
394
394
  names the address `obk message to` would have given. The message goes either
395
395
  way ([ADR 0032](docs/adr/0032-a-native-message-outside-the-fleet-is-warned-about.md)).
396
396
 
397
+ A Claude bot's `.claude/settings.json` also gets
398
+ `"skillOverrides": { "auto-mode-setup": "off" }`, so no session of the bot stops
399
+ on Claude Code's `Teach auto mode about your environment?` form. Your other
400
+ skill overrides are kept. The cost: `/auto-mode-setup` is off in that bot's
401
+ sessions, and your own sessions outside the bots folder keep it. To use it in a
402
+ bot's sessions, set `"auto-mode-setup": "on"` under `skillOverrides` in the bot's
403
+ `.claude/settings.local.json`, which wins over the kit's entry. The kit never
404
+ writes that file.
405
+
397
406
  So: kill a tab, or reboot, and `obk up` brings the session back with the
398
407
  conversation it was having, rather than starting a new one. Clear a session —
399
408
  `/clear` on Claude Code, `/new` on Codex — and the kit writes down the new id,
@@ -616,7 +625,7 @@ verdict: which of these matter, and in what order, is for you or for Bot Father.
616
625
  ## Sessions and bots that talk
617
626
 
618
627
  Ask the kit how to reach a session, and it answers with the road and the
619
- address ([ADR 0042](docs/adr/0042-messaging-transport.md)):
628
+ address ([ADR 0053](docs/adr/0053-messaging-transport.md)):
620
629
 
621
630
  ```sh
622
631
  obk message to --bots /path/to/my-bots --to api-bot/daily
@@ -640,6 +649,13 @@ token, `<bot>.<session>`: `message to` answers the mailbox road for it, never
640
649
  that name, until the kit's launch line next starts it (`obk restart`, or
641
650
  `obk up` after its tab is gone), which gives it a token name of its own.
642
651
 
652
+ It also includes a Claude session that the sender's own Claude Code does not
653
+ list as live. Claude Code's messaging reaches only the sessions in its own
654
+ registry, under its config folder (`CLAUDE_CONFIG_DIR`, or `~/.claude`). After
655
+ a Claude account switch, Orca starts new tabs under a config folder of its own,
656
+ so a session started before the switch and one started after it cannot reach
657
+ each other that way. `message to` and `message send` then take the mailbox.
658
+
643
659
  A session's mailbox is made the first time the kit starts it, and written in
644
660
  the book beside its tab. It is an Orca Run rather than the session's tab,
645
661
  because a tab's address dies with the tab and a Run does not — so a message sent
@@ -669,7 +685,7 @@ keeps a command it was running as a background terminal after an Escape. A
669
685
  receiver reads its mail with `obk message check`, which marks it read: a bare
670
686
  `orca orchestration check` leaves it unread, and Orca then gives no more
671
687
  notices for that mailbox
672
- ([ADR 0042](docs/adr/0042-messaging-transport.md)).
688
+ ([ADR 0053](docs/adr/0053-messaging-transport.md)).
673
689
 
674
690
  A message longer than 4 KiB is written to a file beside your bots folder and
675
691
  named in the message, so a long one arrives whole without landing a document in
package/SETUP.md CHANGED
@@ -222,7 +222,7 @@ answers that screen, by the command and its arguments below the table.
222
222
  | Codex's `/new`: `Where should the new conversation run?` | `\r` | return: its selection starts on **Current checkout** (bot home); on 0.162.0 it is called **1. Use current Git worktree** (Keep using the current working directory). Never **Create new Git worktree** | Codex 0.160.0; Codex 0.162.0 (#516) |
223
223
  | 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 |
224
224
  | `[oh-my-zsh] Would you like to update?` | `n` | they update their own shell | oh-my-zsh bf77e35 |
225
- | Claude Code's `Teach auto mode about your environment?` | `obk session answer`, or `obk temp answer` for a temporary session | the kit sends Esc, which cancels it (**Not now**). On 2.1.283 and 2.1.302 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`. On 2.1.283 the foot starts `←/→ to change usage`, and on 2.1.302 it starts `←/→ to change`. The values after the three settings can differ: `Also scan shell history` showed `true` on 2.1.283, and both `false` and `true` on 2.1.302. The kit sends Esc whatever the values are. **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; the 2.1.302 form was seen live twice, in #604 and in #646, where it showed `Also scan shell history` on `true` and one Esc sent by hand removed it |
225
+ | Claude Code's `Teach auto mode about your environment?` | `obk session answer`, or `obk temp answer` for a temporary session | the kit sends Esc, which cancels it (**Not now**). On 2.1.283, 2.1.302 and 2.1.310 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`. On 2.1.283 the foot starts `←/→ to change usage`, and on 2.1.302 and 2.1.310 it starts `←/→ to change`. Its description wraps to the tab's width, on one row or more. The values after the three settings can differ: `Also scan shell history` showed `true` on 2.1.283, and both `false` and `true` on 2.1.302. The kit sends Esc whatever the values are. **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; the 2.1.302 form was seen live twice, in #604 and in #646, where it showed `Also scan shell history` on `true` and one Esc sent by hand removed it; the 2.1.310 form was seen live in #701, its description on one row, and one Esc sent by hand removed it |
226
226
  | 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 | `obk session answer`, or `obk temp answer` for a temporary session | the kit goes down, then returns, to **2. Not now**: its selection starts on **1. Yes**. The kit reads the screen after the down arrow, and sends 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 |
227
227
 
228
228
  Each key counts on where the selection starts, as it did on the version named.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assuredloop/orca-bot-kit",
3
- "version": "0.34.0",
3
+ "version": "0.35.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -327,7 +327,8 @@ selection on 1. Yes (seen live, not answered, in #489). The kit goes down to
327
327
  form again, with `Also scan shell history` on `false` and a foot that starts
328
328
  `←/→ to change` (seen live in #604), and later with it on `true` (#646). The
329
329
  values of its three settings can differ, and the kit sends it Esc whatever
330
- they are. Codex's hooks
330
+ they are. On 2.1.310 it is the same form, its description wrapped to the
331
+ tab's width, on one row in a wide tab (#701). Codex's hooks
331
332
  question matters most. The kit's hook is how the book learns which
332
333
  conversation the session is running, and until it is answered the
333
334
  conversation has not started. Codex's trust applies to the repository root,
@@ -344,7 +345,7 @@ permission rule can allow that one command, where it cannot allow keys sent
344
345
  into any tab. The same goes for Claude Code's `Teach auto mode` screen on a
345
346
  temporary session you made: answer it with `obk temp answer --bots <bots>
346
347
  --name <session>`. It answers only a shape in the table exactly as the kit
347
- knows it (Esc on the 2.1.283 and 2.1.302 forms, 2. Not now on the 2.1.289 list, the return
348
+ knows it (Esc on the 2.1.283, 2.1.302 and 2.1.310 forms, 2. Not now on the 2.1.289 list, the return
348
349
  only after a second look), refuses any other screen, and checks that it went. The kit's
349
350
  default set allows it, as it allows `obk temp trust-hooks`.
350
351
 
package/src/book.js CHANGED
@@ -341,6 +341,23 @@ export function takeBotTurn(home) {
341
341
  /** Beside the book's lock, with the bot's name: the lock files of its sessions carry one more part. */
342
342
  const botLockFile = (home) => path.join(botLocks(home), `${encodeURIComponent(path.basename(realpathSync(home)))}.bot.lock`);
343
343
 
344
+ /**
345
+ * The right to find or make the bot's Orca project (#712), held by `up` from
346
+ * its look for a `.git` a dead run left in the bot home until the project is
347
+ * there. Two runs of `up` for one bot then never take away the `.git` that
348
+ * the other is registering the home with. A lock goes with its process, so a
349
+ * run that died holds no turn.
350
+ */
351
+ export function takeProjectTurn(home) {
352
+ const file = botLockFile(home).replace(/\.bot\.lock$/, '.project.lock');
353
+ try {
354
+ return lockOn(file, MAILBOX_WAIT_MS);
355
+ } catch (error) {
356
+ if (error instanceof LockNotWritable) throw error;
357
+ throw new Error(`waited ${MAILBOX_WAIT_MS / 1000} seconds for another run to finish making ${home}'s Orca project and it did not (${error.message}). Nothing was changed. Try again; if nothing is running, remove ${file}, which is where runs take their turn.`);
358
+ }
359
+ }
360
+
344
361
  /**
345
362
  * One session's turn at its mailbox (#321): held by `session mailbox` for its
346
363
  * whole step, by `message check` in the session's own tab for its bind, read and
package/src/clear.js CHANGED
@@ -230,10 +230,12 @@ async function enter(it, verb, before = () => {}) {
230
230
  * runs just before the command is typed; `cannot` adds to a refusal what a
231
231
  * wrong screen means; `close`, `{ key, check }`, is one more key typed once
232
232
  * `check` passes, and its own check of the screen before the return; past
233
- * `deadline`, a time, what was typed is taken back and nothing is entered.
233
+ * `deadline`, a time, what was typed is taken back and nothing is entered;
234
+ * `whole` sends the command in one write and not a character a send; `last`
235
+ * runs after the last look, given it, with nothing between it and the first key.
234
236
  * Returns the command, the handle it went into, and when it was typed.
235
237
  */
236
- export async function typeCommand(it, command, { before = () => {}, check = menuWrong, cannot = () => '', close, deadline = Infinity } = {}) {
238
+ export async function typeCommand(it, command, { before = () => {}, check = menuWrong, cannot = () => '', close, deadline = Infinity, whole = false, last = () => {} } = {}) {
237
239
  // Nothing may be in the input line before: no draft for the command to join.
238
240
  // Orca gives a draft in the line beside the screen, and the screen's line
239
241
  // then reads the pointer alone (#510, #516). Where it gives none, Codex's
@@ -262,6 +264,7 @@ export async function typeCommand(it, command, { before = () => {}, check = menu
262
264
  const ready = lookAt(it);
263
265
  if (ready.why !== undefined) throw new Error(`${it.name}: nothing was typed, because ${ready.why}.`);
264
266
  emptyLine(ready);
267
+ last(ready);
265
268
  const { handle } = ready;
266
269
  const typedAt = Date.now();
267
270
  // One character a send, each after a look through the gate, so nothing goes
@@ -297,22 +300,26 @@ export async function typeCommand(it, command, { before = () => {}, check = menu
297
300
  takeBack();
298
301
  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.`);
299
302
  };
300
- const typeKey = (char) => {
303
+ const typeKey = (keys) => {
301
304
  try {
302
- send(handle, char);
305
+ send(handle, keys);
303
306
  } catch (error) {
304
307
  // A send Orca did not answer in time may have gone in all the same.
305
308
  // Counted only where the line was empty before the first key, so the
306
309
  // one backspace too many can take nothing of the user's (#480 review).
307
- if (error.code === TIMED_OUT && (it.harness === 'codex' || it.harness === 'pi')) typed += 1;
310
+ if (error.code === TIMED_OUT && (it.harness === 'codex' || it.harness === 'pi')) typed += [...keys].length;
308
311
  throw error;
309
312
  }
310
- typed += 1;
313
+ typed += [...keys].length;
311
314
  };
312
315
  try {
313
- for (const char of command) {
314
- if (typed > 0) await lookAgain();
315
- typeKey(char);
316
+ // Whole, a write Orca's own notice cannot land inside (#713).
317
+ if (whole) typeKey(command);
318
+ else {
319
+ for (const char of command) {
320
+ if (typed > 0) await lookAgain();
321
+ typeKey(char);
322
+ }
316
323
  }
317
324
  await lookAgain();
318
325
  let wrong = await typedWrong(handle, it.harness, command, version, check);
package/src/cli.js CHANGED
@@ -1319,6 +1319,9 @@ function toLines(answer, bots, where) {
1319
1319
  ...(answer.unnamed === true
1320
1320
  ? [` ${where} is a Claude session running under no name the kit gave it: it was started before the kit named sessions, and nothing renames a live harness. It gets one the next time it starts. Until then the mailbox is the road that reaches it.`]
1321
1321
  : []),
1322
+ ...(answer.unlisted === true
1323
+ ? [` ${where} is a Claude session of your approval class, but your own Claude Code does not list it among the live sessions its messaging reaches: it runs under another Claude config folder, or it is not running. So the mailbox is the road that reaches it.`]
1324
+ : []),
1322
1325
  ` Send it: ${shellWord(ownCli())} message send --bots ${shellWord(bots)} --to ${shellWord(where)} --subject <text> --text <text>`,
1323
1326
  ];
1324
1327
  }
package/src/hooks.js CHANGED
@@ -101,7 +101,8 @@ export function installHook(home, harness, { bots, bot }) {
101
101
  const started = withKitHook(settings.hooks, file, mine);
102
102
  // The Claude Code mail hook and the Codex nudge hook went with the kit's
103
103
  // typed line (#555), so an entry of either is taken out.
104
- const wanted = { ...settings, hooks: toolHooksOf(harness, bots, bot).reduce(withToolHook, withoutHooks(started, REMOVED_HOOKS)) };
104
+ const hooked = { ...settings, hooks: toolHooksOf(harness, bots, bot).reduce(withToolHook, withoutHooks(started, REMOVED_HOOKS)) };
105
+ const wanted = harness === 'claude' ? { ...hooked, skillOverrides: withTeachOff(settings.skillOverrides, file) } : hooked;
105
106
 
106
107
  // Compared as documents, not as text: how the user laid their file out is
107
108
  // theirs, and a run that changes nothing writes nothing.
@@ -278,6 +279,29 @@ function withKitHook(hooks, file, mine) {
278
279
  return { ...events, [EVENT]: [...(events[EVENT] ?? []), { hooks: [mine] }] };
279
280
  }
280
281
 
282
+ /**
283
+ * The file's skill overrides with Claude Code's `auto-mode-setup` off, so no
284
+ * session of the bot stops on its "Teach auto mode about your environment?"
285
+ * form (#708, teach-form-off/R1). Claude Code 2.1.310 shows the form only when
286
+ * the merged settings leave that command on, and a project file counts (tech
287
+ * notes). Every other override is the user's and stays. A user who wants the
288
+ * command in the bot's sessions turns it on in `.claude/settings.local.json`,
289
+ * which wins over this file and which the kit never writes.
290
+ *
291
+ * Anything there that is not a mapping is theirs, and is refused rather than
292
+ * written over, as a `hooks` entry is.
293
+ */
294
+ function withTeachOff(overrides, file) {
295
+ if (overrides === undefined) return { [TEACH_SKILL]: 'off' };
296
+ if (overrides === null || typeof overrides !== 'object' || Array.isArray(overrides)) {
297
+ throw new Error(`${file} has a skillOverrides entry that is not a mapping of skill names, and the kit turns Claude Code's ${TEACH_SKILL} off in there. Fix it or move it aside, then run the command again.`);
298
+ }
299
+ return { ...overrides, [TEACH_SKILL]: 'off' };
300
+ }
301
+
302
+ /** Claude Code's command behind its Teach auto mode form. */
303
+ const TEACH_SKILL = 'auto-mode-setup';
304
+
281
305
  /** Each harness's kit hooks beside its session hook, as `toolHookOf` gives each. */
282
306
  const toolHooksOf = (harness, bots, bot, cli = ownCli()) =>
283
307
  (harness === 'claude' ? ['sent'] : ['name']).map((kind) => toolHookOf(kind, bots, bot, cli));
package/src/launch.js CHANGED
@@ -241,7 +241,7 @@ export const piSkillsDir = (home) => path.join(home, '.agents', 'skills');
241
241
 
242
242
  /**
243
243
  * A session's name on Claude Code, which is also the address another Claude
244
- * session writes to (ADR 0042). It goes on the launch line as `-n`, and it is
244
+ * session writes to (ADR 0053). It goes on the launch line as `-n`, and it is
245
245
  * re-applied every time the session is started: a resume keeps the name by
246
246
  * itself (tech notes, section 2), and the kit does not depend on that.
247
247
  *
@@ -435,7 +435,7 @@ export function launchCommand(session, { harness, home, workDir, prompt, promptF
435
435
 
436
436
  if (harness === 'claude') {
437
437
  // The name is the address other Claude sessions write to, so it goes on
438
- // every launch line, a resume included (ADR 0042).
438
+ // every launch line, a resume included (ADR 0053).
439
439
  if (address !== undefined) words.push('-n', address);
440
440
  // The context window rides on the model name: `sonnet[1m]`.
441
441
  if (set(session.model)) words.push('--model', set(session.context) ? `${session.model}[${session.context}]` : session.model);
package/src/message.js CHANGED
@@ -1,4 +1,4 @@
1
- // `obk message`: one session writing to another (PRD 6.9, ADR 0042).
1
+ // `obk message`: one session writing to another (PRD 6.9, ADR 0053).
2
2
  //
3
3
  // Two roads, and the bot never picks. Claude to Claude in the same approval
4
4
  // class is the harness's own messaging, which no command line can send for it —
@@ -10,20 +10,21 @@
10
10
  // refuses a send once the pane is gone. A Run survives the tab, the relaunch
11
11
  // and the restart, so a session's address is its Run (tech notes, section 1).
12
12
  //
13
- // One road and one signal (#555, ADR 0042): the letter goes into the
13
+ // One road and one signal (#555, ADR 0053): the letter goes into the
14
14
  // receiver's mailbox, and Orca's own notice tells the receiver to look once it
15
15
  // is idle. The kit types nothing into the receiver's tab, except the one
16
16
  // Escape a sender asks for with --interrupt. Orca gives no notice to a tab its
17
17
  // window has not loaded, so the send says when the receiver's is one (#579).
18
18
 
19
- import { mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
19
+ import { mkdirSync, readdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
20
+ import { homedir } from 'node:os';
20
21
  import path from 'node:path';
21
22
  import { setTimeout as pause } from 'node:timers/promises';
22
23
 
23
24
  import { MAILBOX_WAIT_MS, readBook, takeLineTurn, takeMailboxTurn, TYPING_HELD, TYPING_WAIT_MS } from './book.js';
24
25
  import { botDir, botNames, readBot } from './bot.js';
25
26
  import { harnessOf, isAddressOf, kitFolders, ownCli, reachesMail, SHELL_ENV, shellWord } from './launch.js';
26
- import { ackMailbox, coordinatorOf, listedOrphaned, loadInWindow, postMessage, pressEscape, readMailbox, tabs, tabToTypeInto, TERMINAL_ENV, TIMED_OUT, useMailbox } from './orca.js';
27
+ import { ackMailbox, coordinatorOf, isRunning, listedOrphaned, loadInWindow, postMessage, pressEscape, readMailbox, tabs, tabToTypeInto, TERMINAL_ENV, TIMED_OUT, useMailbox } from './orca.js';
27
28
 
28
29
  /**
29
30
  * How much of a message travels as itself. Above this it is written to a file
@@ -89,11 +90,16 @@ export function findSession(bots, target) {
89
90
  */
90
91
  export function roadBetween(from, to) {
91
92
  const pair = from.harness === 'claude' && to.harness === 'claude' && from.approval === to.approval;
92
- if (pair && to.address !== undefined) return { transport: 'native', address: to.address };
93
+ const mailbox = to.mailbox === undefined ? undefined : `run:${to.mailbox}`;
94
+ if (pair && to.address !== undefined) {
95
+ // Native only to a session the sender's own Claude Code lists: its
96
+ // messaging reaches no other, and the mailbox does (#700).
97
+ return listedLive(to.address) ? { transport: 'native', address: to.address } : { transport: 'orca', address: mailbox, unlisted: true };
98
+ }
93
99
 
94
100
  return {
95
101
  transport: 'orca',
96
- address: to.mailbox === undefined ? undefined : `run:${to.mailbox}`,
102
+ address: mailbox,
97
103
  // A Claude pair that the native road cannot carry, because the receiver is
98
104
  // running under no name the kit gave it: it was started before the kit
99
105
  // named sessions, or before it gave them names of their own (#286), and
@@ -104,6 +110,38 @@ export function roadBetween(from, to) {
104
110
  };
105
111
  }
106
112
 
113
+ /**
114
+ * Whether the sender's own Claude Code lists `address` as a live session. Its
115
+ * messaging reaches only the sessions in its own registry, one
116
+ * `<pid>.json` for each, in `sessions` under its config dir, and two Claude
117
+ * Codes with two config dirs do not see each other: Orca starts a tab under a
118
+ * Claude profile of its own after an account switch, and a session started
119
+ * before it is in `~/.claude` (#700; tech notes, section 2). The command runs
120
+ * in the sender's environment, so its CLAUDE_CONFIG_DIR is the sender's. An
121
+ * entry counts when its `name` is the address, unless `ps` says its pid is not
122
+ * there: a check that cannot tell keeps the entry, so a slow `ps` opens no
123
+ * second road to a session the sender can reach (review of PR #702). A folder
124
+ * or a file that cannot be read lists nobody.
125
+ */
126
+ function listedLive(address) {
127
+ const registry = path.join(process.env.CLAUDE_CONFIG_DIR || path.join(homedir(), '.claude'), 'sessions');
128
+ let files;
129
+ try {
130
+ files = readdirSync(registry).filter((file) => file.endsWith('.json'));
131
+ } catch {
132
+ return false;
133
+ }
134
+ return files.some((file) => {
135
+ let entry;
136
+ try {
137
+ entry = JSON.parse(readFileSync(path.join(registry, file), 'utf8'));
138
+ } catch {
139
+ return false;
140
+ }
141
+ return entry?.name === address && Number.isInteger(entry.pid) && entry.pid > 0 && isRunning(entry.pid, LOOK_MS) !== false;
142
+ });
143
+ }
144
+
107
145
  /** What `obk message to` answers: the road, the address, and who is at each end. */
108
146
  export function lookUp(bots, { to: target, from: sender, tab }) {
109
147
  const from = whoIsWriting(bots, sender, tab);
package/src/name.js CHANGED
@@ -19,6 +19,7 @@ import { readBook, takeNameTurn, takeTypingTurn } from './book.js';
19
19
  import { botDir, readBot } from './bot.js';
20
20
  import { sessionToType, typeCommand } from './clear.js';
21
21
  import { harnessOf } from './launch.js';
22
+ import { mailIn } from './orca.js';
22
23
  import { sessionsOf } from './up.js';
23
24
 
24
25
  /**
@@ -40,6 +41,9 @@ const DEADLINE_MS = 240_000;
40
41
  const CONFIRM_MS = 10_000;
41
42
  const ASK_MS = 500;
42
43
 
44
+ /** How many of a mailbox's newest letters are read, as `obk health` reads them. */
45
+ const MAIL_READ = 100;
46
+
43
47
  /**
44
48
  * Name the thread that just ended a turn in the tab `tabId`, as Codex's Stop
45
49
  * hook `said` it, when that tab is a Codex session of `bot` and its thread does
@@ -85,8 +89,27 @@ export async function nameSession(bots, bot, said, tabId) {
85
89
  typing = takeTypingTurn(home, session, 0);
86
90
  if (typing === undefined) throw new Error(`${it.name}: nothing was typed, because the kit is typing into its tab already.`);
87
91
  };
92
+ // Orca types its notice into an idle tab, the input line or not, and a
93
+ // notice inside the command became the thread's name (#713). A letter not
94
+ // delivered yet is one Orca is about to ring for: name later. Read last
95
+ // of all before the write.
96
+ let footer;
97
+ const last = (ready) => {
98
+ footer = rowUnder(ready.rows);
99
+ const mailbox = entry.mailbox;
100
+ if (mailbox === undefined) return;
101
+ let letters;
102
+ try {
103
+ letters = mailIn(mailbox, MAIL_READ);
104
+ } catch (error) {
105
+ throw new Error(`${it.name}: nothing was typed, because its mailbox could not be read: ${error.message}`);
106
+ }
107
+ if (letters.some((letter) => !letter.read && letter.delivered_at == null)) {
108
+ throw new Error(`${it.name}: nothing was typed, because Orca has yet to tell it of mail.`);
109
+ }
110
+ };
88
111
  try {
89
- await typeCommand(it, `/rename ${name}`, { check: renameWrong, deadline, before });
112
+ await typeCommand(it, `/rename ${name}`, { check: renameWrong(() => footer), deadline, before, last, whole: true });
90
113
  } finally {
91
114
  typing?.release();
92
115
  }
@@ -119,6 +142,12 @@ function nameOf(thread) {
119
142
  return name;
120
143
  }
121
144
 
145
+ /** The first row with text under the input line, the lowest row Codex's pointer starts, or undefined. */
146
+ function rowUnder(rows) {
147
+ const at = rows.findLastIndex((row) => /^ *›/.test(row));
148
+ return rows.slice(at + 1).find((row) => row.trim() !== '')?.trimEnd();
149
+ }
150
+
122
151
  /**
123
152
  * Why the screen does not show `command` typed and ready, or undefined when it
124
153
  * does: the input line, the lowest row Codex's pointer starts, reads the
@@ -127,13 +156,18 @@ function nameOf(thread) {
127
156
  * `sync_command_popup`, rust-v0.160.0), so a menu still open means the line is
128
157
  * not what it seems. Where Orca gives a draft, the draft is the line's text:
129
158
  * Codex 0.162.0's line on the screen then reads its pointer alone (#516).
159
+ * Under the line, the first row with text is the one that stood there before
160
+ * the write, `footer()`: Codex's status row, whatever the user set it to show.
130
161
  */
131
- function renameWrong(rows, _harness, command, _version, draft) {
162
+ const renameWrong = (footer) => (rows, _harness, command, _version, draft) => {
132
163
  const at = rows.findLastIndex((row) => /^ *›/.test(row));
133
164
  if (at < 0) return { why: 'its screen shows no input line' };
134
165
  const line = draft === undefined ? rows[at].replaceAll(' ', ' ').trim() : `› ${draft}`;
135
166
  if (line !== `› ${command}`) return { why: `its input line reads "${line}"` };
136
167
  const above = rows.slice(0, at).findLast((row) => row.trim() !== '');
137
168
  if (above !== undefined && /^ *(?:› +)?\/\S/.test(above)) return { why: `a menu of commands stands above its input line: "${above.trim()}"` };
169
+ // Any other row there is more of the line: Orca's notice, for one (#713).
170
+ const under = rowUnder(rows);
171
+ if (under !== footer()) return { why: `text stands under its input line: "${under?.trim()}"` };
138
172
  return undefined;
139
- }
173
+ };
package/src/orca.js CHANGED
@@ -8,7 +8,7 @@
8
8
  // handed to Orca on the command line.
9
9
 
10
10
  import { spawnSync } from 'node:child_process';
11
- import { existsSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
11
+ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
12
12
  import { homedir, tmpdir } from 'node:os';
13
13
  import path from 'node:path';
14
14
  import { DatabaseSync } from 'node:sqlite';
@@ -290,12 +290,63 @@ function argsIn(read) {
290
290
  * repo as git kind, which Orca gives no workspace at all, so no tab can be
291
291
  * created in it. `setup-update` turns it into the folder workspace a bot needs
292
292
  * and names the project in one go.
293
+ *
294
+ * Orca 1.4.236 and later register the top folder of the git repo that holds
295
+ * the folder they are given: for a bot home, the whole bots folder (#712). So
296
+ * for the time of `repo add` the home gets a `.git` of its own, which makes it
297
+ * its own top folder, and that `.git` goes straight after. One that was there
298
+ * before is the user's, and stays as it was.
293
299
  */
294
300
  export function makeProject(home, title) {
295
- const setupId = orca(['repo', 'add', '--path', home]).repo.id;
301
+ const made = gitOfItsOwn(home);
302
+ let setupId;
303
+ try {
304
+ setupId = orca(['repo', 'add', '--path', home]).repo.id;
305
+ } finally {
306
+ if (made) rmSync(path.join(home, '.git'), { recursive: true, force: true });
307
+ }
296
308
  return asFolderProject(setupId, title);
297
309
  }
298
310
 
311
+ /** The file inside a `.git` that says the kit made it for one `repo add`. */
312
+ const KIT_GIT_MARK = 'obk-made-for-orca';
313
+
314
+ /**
315
+ * Give `home` a `.git` of its own: true when this call made it, false when one
316
+ * is there already. The mkdir fails on anything already there, so the kit
317
+ * never takes over a `.git` it did not make, and the mark goes in before git
318
+ * fills it, so one left by a run that died is known as the kit's.
319
+ */
320
+ function gitOfItsOwn(home) {
321
+ const git = path.join(home, '.git');
322
+ try {
323
+ mkdirSync(git);
324
+ } catch (error) {
325
+ if (error.code === 'EEXIST') return false;
326
+ throw error;
327
+ }
328
+ try {
329
+ writeFileSync(path.join(git, KIT_GIT_MARK), 'obk made this for one `orca repo add` and takes it away after.\n');
330
+ const result = spawnSync('git', ['init', '--quiet', home], { encoding: 'utf8' });
331
+ if (result.error) throw new Error(`could not run git: ${result.error.message}`);
332
+ if (result.status !== 0) throw new Error(`git init failed in ${home}: ${(result.stderr || '').trim()}`);
333
+ } catch (error) {
334
+ rmSync(git, { recursive: true, force: true });
335
+ throw error;
336
+ }
337
+ return true;
338
+ }
339
+
340
+ /**
341
+ * Take away the `.git` that a run which died during `makeProject` left in
342
+ * `home`. Only one that holds the kit's mark: any other is the user's.
343
+ */
344
+ export function dropLeftGit(home) {
345
+ const git = path.join(home, '.git');
346
+ const stat = lstatSync(git, { throwIfNoEntry: false });
347
+ if (stat?.isDirectory() && existsSync(path.join(git, KIT_GIT_MARK))) rmSync(git, { recursive: true, force: true });
348
+ }
349
+
299
350
  /** Make an existing registration a folder workspace called `title`. */
300
351
  export function asFolderProject(setupId, title) {
301
352
  return orca(['project', 'setup-update', '--setup', setupId, '--kind', 'folder', '--display-name', title])
@@ -1048,9 +1099,25 @@ export function wordsOfProcess(pid) {
1048
1099
  return asked.stdout.trim().split(/\s+/);
1049
1100
  }
1050
1101
 
1102
+ /**
1103
+ * Whether the process `pid` runs now, as `ps` answers for it: false only when
1104
+ * `ps` says no process has that pid, and undefined when it cannot tell: `ps`
1105
+ * did not start, failed, ran out of time or printed a line it cannot read.
1106
+ * macOS's `ps` answers a pid that is not there with exit 1 and nothing on
1107
+ * stdout or stderr (seen on macOS 27.0.1), and it exits 1 on an error of its
1108
+ * own too, with the error on stderr (review of PR #702).
1109
+ */
1110
+ export function isRunning(pid, readMs) {
1111
+ const asked = askPs(pid, readMs);
1112
+ if (asked.error === undefined && asked.status === 1 && asked.stdout.trim() === '' && asked.stderr.trim() === '') return false;
1113
+ return psLine(pid, readMs, asked)?.comm === undefined ? undefined : true;
1114
+ }
1115
+
1116
+ const askPs = (pid, readMs) =>
1117
+ spawnSync(PS, ['-o', 'pid=,ppid=,tpgid=,comm=', '-p', String(pid)], { encoding: 'utf8', ...(readMs === undefined ? {} : { timeout: readMs }) });
1118
+
1051
1119
  /** One process as `ps` gives it, read only: `{ ppid, tpgid, comm }`, `{ late: true }` past `readMs`, or undefined. */
1052
- function psLine(pid, readMs) {
1053
- const asked = spawnSync(PS, ['-o', 'pid=,ppid=,tpgid=,comm=', '-p', String(pid)], { encoding: 'utf8', ...(readMs === undefined ? {} : { timeout: readMs }) });
1120
+ function psLine(pid, readMs, asked = askPs(pid, readMs)) {
1054
1121
  if (asked.error?.code === 'ETIMEDOUT') return { late: true };
1055
1122
  if (asked.error || asked.status !== 0) return undefined;
1056
1123
  const line = /^\s*(\d+)\s+(\d+)\s+(-?\d+)\s+(.+?)\s*$/.exec(asked.stdout);
@@ -55,7 +55,7 @@ export function defaultRules(bots, cli = ownCli()) {
55
55
  /**
56
56
  * Orca's check, which the set held until #564: the kit's rules tell a bot to
57
57
  * read its mail with `obk message check`, never with the bare check, which
58
- * leaves it unread (ADR 0042).
58
+ * leaves it unread (ADR 0053).
59
59
  */
60
60
  const ORCA_CHECK = 'Bash(orca orchestration check --run:*)';
61
61
 
package/src/retire.js CHANGED
@@ -17,7 +17,7 @@
17
17
  //
18
18
  // Closing a tab here ends the conversation in it on purpose, so unlike a
19
19
  // restart or a pause it does not wait for the book to know which one it was.
20
- // The mailbox Runs stay: Orca has no way to remove one (ADR 0042).
20
+ // The mailbox Runs stay: Orca has no way to remove one (ADR 0053).
21
21
  //
22
22
  // What a session started in its work dir and left running is stopped once its
23
23
  // tab is closed, and named (#537): see processes.js.
package/src/sent.js CHANGED
@@ -8,11 +8,14 @@
8
8
  // has gone either way. Nothing here decides anything about it, and anything it
9
9
  // cannot read is silence (ADR 0022: a hook never disturbs the session).
10
10
  //
11
- // It reads the send from its fields, never from its wording. Seen on Claude
12
- // Code 2.1.283: a send to an in-process teammate answers with a `routing`
13
- // object, and one to another session without it; the input's `to` names the
14
- // target, as a session's name with an optional ` [xxxxxx]` reference, or as a
15
- // `uds:` socket path when answering a message, which no book can map.
11
+ // It reads the send from its fields, never from its wording. Seen in the
12
+ // transcripts of Claude Code 2.1.281 to 2.1.310 (#705): a send to another
13
+ // session that went answers with a `msg_id` and with neither `routing` nor
14
+ // `pin`. A send to a subagent or teammate inside the process has `routing` or
15
+ // `pin`, or no `msg_id`. The transcript also has a `display`, but the copy the
16
+ // model reads does not, and the hook may get either. The input's `to` names
17
+ // the target, as a session's name with an optional ` [xxxxxx]` reference, or
18
+ // as a `uds:` socket path when answering a message, which no book can map.
16
19
 
17
20
  import { readBook } from './book.js';
18
21
  import { botDir, botNames } from './bot.js';
@@ -28,9 +31,8 @@ export function sentWarning(bots, said) {
28
31
  if (said?.tool_name !== 'SendMessage') return undefined;
29
32
  const to = said.tool_input?.to;
30
33
  if (typeof to !== 'string') return undefined;
31
- const result = resultOf(said);
32
- // A teammate in the same session, or a send that did not go: not this.
33
- if (result === undefined || result.success !== true || 'routing' in result) return undefined;
34
+ // A subagent or teammate in the same session, or a send that did not go: not this.
35
+ if (!wentToAnotherSession(resultOf(said))) return undefined;
34
36
  if (/^(uds|bridge):/.test(to)) return undefined;
35
37
 
36
38
  const name = to.replace(/\s*\[[^\]]*\]\s*$/, '').trim();
@@ -70,6 +72,12 @@ function roadTo(bots, meant, ask) {
70
72
  return `If you meant ${who}, its session address is ${meant.address}; ${ask} says which road to take from your session.`;
71
73
  }
72
74
 
75
+ /** Whether the send answered as one that went to another session. */
76
+ function wentToAnotherSession(result) {
77
+ return result !== undefined && result.success === true && 'msg_id' in result
78
+ && !('routing' in result) && !('pin' in result);
79
+ }
80
+
73
81
  /** What the send answered, as an object: Claude Code hands it over as one, or as its JSON. */
74
82
  function resultOf(said) {
75
83
  const raw = said.tool_output ?? said.tool_response;
package/src/temp.js CHANGED
@@ -552,11 +552,17 @@ const TEACH_FOOT = /Enter to .*· Esc to cancel/;
552
552
  */
553
553
  const setting = (name) => ({ name, fits: (row) => row.startsWith(name) && /^ +\S/.test(row.slice(name.length)) });
554
554
 
555
+ /**
556
+ * Text Claude Code wraps at word breaks to the tab's width, on one row or
557
+ * more: its words, in order, and nothing else (#701). Claude Code 2.1.310
558
+ * holds the Teach form's description as one string (read in its code).
559
+ */
560
+ const wrapped = (text) => ({ text });
561
+
555
562
  /** The Teach form's rows, from its title to its foot, with `foot`. */
556
563
  const teachForm = (foot) => [
557
564
  TEACH_TITLE,
558
- 'Claude Code reads this project, your recent Claude sessions, and optionally your shell history and other',
559
- 'repositories. Claude analyzes this data and customizes auto mode to make better decisions.',
565
+ wrapped('Claude Code reads this project, your recent Claude sessions, and optionally your shell history and other repositories. Claude analyzes this data and customizes auto mode to make better decisions.'),
560
566
  setting('How you use Claude here'),
561
567
  setting('Also scan shell history'),
562
568
  setting('Also scan your other repos'),
@@ -568,11 +574,12 @@ const teachForm = (foot) => [
568
574
  * The known shapes of the Teach screen, from its title to its foot, each as
569
575
  * captured live, with what answers it. Rows are compared trimmed, the pointer
570
576
  * `❯` taken out, so the pointer may sit on any one of them. A setting row
571
- * matches by its name, whatever its value.
577
+ * matches by its name, whatever its value, and the form's description by its
578
+ * words, on as many rows as the tab's width wraps it onto (#701).
572
579
  *
573
- * Claude Code 2.1.283's form (#261's live test, #416) and 2.1.302's (#604) are
574
- * answered with Esc, which cancels them (Not now), never a return, which is
575
- * Continue. Claude Code 2.1.289's numbered list (#489's live run 1) is
580
+ * Claude Code 2.1.283's form (#261's live test, #416) and 2.1.302's (#604),
581
+ * which 2.1.310 draws too (#701), are answered with Esc, which cancels them
582
+ * (Not now), never a return, which is Continue. Claude Code 2.1.289's numbered list (#489's live run 1) is
576
583
  * answered with "2. Not now", never "1. Yes" or "3. Don't show again" (the
577
584
  * architect's ruling on #489).
578
585
  */
@@ -599,10 +606,30 @@ const NOT_NOW_CHOICE = '2. Not now';
599
606
  const unpointed = (row) => row.replace('❯', ' ').trim();
600
607
 
601
608
  /** Whether a shape's row `want` is the screen's `row`, as unpointed gives it. */
602
- const fitsRow = (want, row) => (typeof want === 'string' ? row === want : want.fits(row));
609
+ const fitsRow = (want, row) => (typeof want === 'string' ? row === want : want.text !== undefined ? row === want.text : want.fits(row));
603
610
 
604
611
  /** A shape's row as a refusal names it. */
605
- const shapeRow = (want) => (typeof want === 'string' ? want : `${want.name} and a value`);
612
+ const shapeRow = (want) => (typeof want === 'string' ? want : want.text ?? `${want.name} and a value`);
613
+
614
+ /**
615
+ * `block` with the first run of rows that is `shape`'s wrapped text, joined
616
+ * with single spaces, put on one row, unpointed; `block` as it is when there
617
+ * is no such run.
618
+ */
619
+ function unwrapped(block, shape) {
620
+ const text = shape.rows.find((want) => want.text !== undefined)?.text;
621
+ if (text === undefined) return block;
622
+ for (let from = 0; from < block.length; from += 1) {
623
+ let joined = unpointed(block[from]);
624
+ for (let to = from; text.startsWith(joined); ) {
625
+ if (joined === text) return [...block.slice(0, from), text, ...block.slice(to + 1)];
626
+ to += 1;
627
+ if (to === block.length) break;
628
+ joined = `${joined} ${unpointed(block[to])}`;
629
+ }
630
+ }
631
+ return block;
632
+ }
606
633
 
607
634
  /** The full-width rule Claude Code draws right above the screen's title: `─` in 2.1.289, `▔` in 2.1.283. */
608
635
  const ABOVE_TITLE = /^(?:─+|▔+)$/;
@@ -637,13 +664,14 @@ function teachScreen(rows) {
637
664
  const misfits = TEACH_SHAPES.map((shape) => {
638
665
  const pointers = block.filter((row) => row.includes('❯')).length;
639
666
  if (pointers !== 1) return `${pointers} rows with the pointer ❯, where ${shape.name} has one`;
640
- const odd = block.find((row) => !shape.rows.some((want) => fitsRow(want, unpointed(row))));
667
+ const folded = unwrapped(block, shape);
668
+ const odd = folded.find((row) => !shape.rows.some((want) => fitsRow(want, unpointed(row))));
641
669
  if (odd !== undefined) return `a row ${shape.name} does not have: ${odd.trim()}`;
642
- const missing = shape.rows.find((want) => !block.some((row) => fitsRow(want, unpointed(row))));
670
+ const missing = shape.rows.find((want) => !folded.some((row) => fitsRow(want, unpointed(row))));
643
671
  if (missing !== undefined) return `no row ${shapeRow(missing)}, which ${shape.name} has`;
644
- if (block.length !== shape.rows.length) return `${block.length} rows, where ${shape.name} has ${shape.rows.length}`;
645
- const moved = block.findIndex((row, at) => !fitsRow(shape.rows[at], unpointed(row)));
646
- if (moved >= 0) return `the row ${block[moved].trim()} where ${shape.name} has ${shapeRow(shape.rows[moved])}`;
672
+ if (folded.length !== shape.rows.length) return `${folded.length} rows, where ${shape.name} has ${shape.rows.length}`;
673
+ const moved = folded.findIndex((row, at) => !fitsRow(shape.rows[at], unpointed(row)));
674
+ if (moved >= 0) return `the row ${folded[moved].trim()} where ${shape.name} has ${shapeRow(shape.rows[moved])}`;
647
675
  return undefined;
648
676
  });
649
677
  const fits = misfits.findIndex((misfit) => misfit === undefined);
package/src/up.js CHANGED
@@ -9,14 +9,14 @@ import { existsSync, mkdirSync, realpathSync, writeFileSync } from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import { setTimeout as pause } from 'node:timers/promises';
11
11
 
12
- import { forgetClaimed, forgetSession, MAILBOX_WAIT_MS, readBook, rememberSession, sessionIdsIn, tabIdsIn, takeLineTurn, takeMailboxTurn, TYPING_HELD, TYPING_WAIT_MS, updateBook, withUnclaimed } from './book.js';
12
+ import { forgetClaimed, forgetSession, MAILBOX_WAIT_MS, readBook, rememberSession, sessionIdsIn, tabIdsIn, takeLineTurn, takeMailboxTurn, takeProjectTurn, TYPING_HELD, TYPING_WAIT_MS, updateBook, withUnclaimed } from './book.js';
13
13
  import { botDir, botNames, displayName, readBot } from './bot.js';
14
14
  import { conversationsIn, hasConversation, heldAsUserTurn, transcriptsIn } from './conversations.js';
15
15
  import { dropRemovedHooks, installHook } from './hooks.js';
16
16
  import { writePermissions } from './permissions.js';
17
17
  import { piTrouble } from './pi.js';
18
- import { addressOf, HARNESSES, harnessOf, isAddressOf, isShortPrompt, kitFolders, launchCommand, mailboxStep, reachesMail, sessionTrouble, startPrompt, workDirOf } from './launch.js';
19
- import { asFolderProject, coordinatorOf, findProject, harnessInTab, loadInWindow, makeMailbox, makeProject, openTab, QUESTION_ON_SCREEN, retitleTab, runMissing, screenRows, shellInTab, tabs, tabToTypeInto, TERMINAL_ENV, TIMED_OUT, tellWindow, typeIntoTab, useMailbox } from './orca.js';
18
+ import { addressOf, HARNESSES, harnessOf, isAddressOf, isShortPrompt, kitFolders, launchCommand, mailboxStep, ownCli, reachesMail, sessionTrouble, shellWord, startPrompt, workDirOf } from './launch.js';
19
+ import { asFolderProject, coordinatorOf, dropLeftGit, findProject, harnessInTab, loadInWindow, makeMailbox, makeProject, openTab, QUESTION_ON_SCREEN, retitleTab, runMissing, screenRows, shellInTab, tabs, tabToTypeInto, TERMINAL_ENV, TIMED_OUT, tellWindow, typeIntoTab, useMailbox } from './orca.js';
20
20
  import { TAB_ENV } from './record.js';
21
21
  import { buildAgents, rulesStamp } from './rules.js';
22
22
  import { linkSkills } from './skills.js';
@@ -151,12 +151,14 @@ export function prepareBots(bots, names, onlySession) {
151
151
  // and a bot folder the kit cannot write it into stops the run with nothing
152
152
  // opened anywhere. Only the harnesses a bot actually runs on; a bot with no
153
153
  // sessions gets none. The hooks of commands the kit no longer has go from
154
- // every hooks file the bot has, of a harness it has left too (#613).
154
+ // every hooks file the bot has, of a harness it has left too (#613). That
155
+ // comes after the kit's own hooks, which take them out of the files they
156
+ // write anyway, so a file the kit refuses is left as the user wrote it (#708).
155
157
  for (const { bot, home } of running) {
156
- dropRemovedHooks(home);
157
158
  for (const harness of new Set(awake(sessionsOf(bot, onlySession)).map((session) => harnessOf(session, bot.harness)))) {
158
159
  installHook(home, harness, { bots, bot: bot.name });
159
160
  }
161
+ dropRemovedHooks(home);
160
162
  }
161
163
 
162
164
  // And the permission rules the user allowed, beside the hook and for the same
@@ -433,7 +435,7 @@ async function bringUpSession(bots, home, live, session, bot, title) {
433
435
  // 0.157.1 (#226). So it is given one line, through the same gate as a mail
434
436
  // notice, which the owner allowed. A fresh start's first turn is its prompt.
435
437
  const list = harness === 'codex' && launch.resume !== undefined && tui.running === true
436
- ? await typeListLine(home, session.name, made.tabId)
438
+ ? await typeListLine(home, session.name, made.tabId, { line: listLine(bots) })
437
439
  : undefined;
438
440
 
439
441
  return entry(made, {
@@ -503,8 +505,15 @@ async function shellNotReady(handle) {
503
505
  : `the shell is asking: ${last.trim()} (${wait})`;
504
506
  }
505
507
 
506
- /** The line a resumed Codex session is typed so Orca lists it, in the architect's words (#226). */
507
- const LIST_LINE = 'obk: this session was resumed in a new tab, and this line is only so Orca lists it. Reply "ok"; nothing else is asked.';
508
+ /**
509
+ * The line a resumed Codex session is typed so Orca lists it, in the
510
+ * architect's words (#226). It names the kit's mail check as the bot's rules
511
+ * do, because a restart often comes for mail that is waiting (#630), and a line
512
+ * that asked for "ok" alone had the session drop the mail it had just read (#709).
513
+ */
514
+ const listLine = (bots) => 'obk: this session was resumed in a new tab, and the kit types this line so Orca lists it.'
515
+ + ` Read your mail with \`${shellWord(ownCli())} message check --bots ${shellWord(bots)}\`, and do what it asks.`
516
+ + ' That includes mail you read just before this line. If there is none, reply "ok".';
508
517
 
509
518
  /**
510
519
  * How long a resumed Codex tab is given to be one the kit may type into: live,
@@ -516,15 +525,15 @@ const LIST_LOOK_MS = 2000;
516
525
  const LIST_ASK_MS = 500;
517
526
 
518
527
  /**
519
- * Type `line`, LIST_LINE unless told otherwise, into the tab `tabId` of
520
- * `session` once the gate every typed line goes through lets it:
528
+ * Type `line` into the tab `tabId` of `session` once the gate every typed
529
+ * line goes through lets it:
521
530
  * `{ typed: true }`, or `{ typed: false, why }` with the gate's last reason
522
531
  * once LIST_WAIT_MS is up. Nothing goes into a question, a form or a menu
523
532
  * (#329, #416), nor into a tab the kit cannot tell about. Each look and its
524
533
  * line are taken in the session's turn for a line (#482), unless the caller
525
534
  * says it holds the session's typing turn already (`held`).
526
535
  */
527
- export async function typeListLine(home, session, tabId, { line = LIST_LINE, held = false } = {}) {
536
+ export async function typeListLine(home, session, tabId, { line, held = false }) {
528
537
  const until = Date.now() + LIST_WAIT_MS;
529
538
  for (;;) {
530
539
  let turn;
@@ -926,9 +935,15 @@ const setAside = (entry, which) => (which.ranOn === undefined
926
935
  * all, so no tab can be opened in it.
927
936
  */
928
937
  function orcaProject(home, title) {
929
- const found = findProject(home);
930
- if (found === undefined) return ids(makeProject(home, title), 'made');
931
- return found.kind === 'folder' ? ids(found) : ids(asFolderProject(found.id, title), 'renamed');
938
+ const turn = takeProjectTurn(home);
939
+ try {
940
+ dropLeftGit(home);
941
+ const found = findProject(home);
942
+ if (found === undefined) return ids(makeProject(home, title), 'made');
943
+ return found.kind === 'folder' ? ids(found) : ids(asFolderProject(found.id, title), 'renamed');
944
+ } finally {
945
+ turn.release();
946
+ }
932
947
  }
933
948
 
934
949
  const ids = (setup, change) => ({ project: setup.projectId, setup: setup.id, change });