@assuredloop/orca-bot-kit 0.33.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
@@ -126,7 +126,9 @@ default and the kit will not guess. It is Bot Father's own harness only; each
126
126
  bot they make later chooses its own. If only one harness is installed, say so
127
127
  and confirm it rather than asking as though the choice were open. If they choose
128
128
  `pi`, they sign in to Pi themselves first: the kit never handles Pi's
129
- credentials, and `obk init` starts nothing while Pi can use no model.
129
+ credentials, and `obk init` starts nothing while Pi can use no model. When you
130
+ ask or confirm, tell them: "On Pi, Bot Father acts without asking first, because
131
+ Pi has no approval step."
130
132
 
131
133
  ## 4. Make the fleet
132
134
 
@@ -220,7 +222,7 @@ answers that screen, by the command and its arguments below the table.
220
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) |
221
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 |
222
224
  | `[oh-my-zsh] Would you like to update?` | `n` | they update their own shell | oh-my-zsh bf77e35 |
223
- | 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 |
224
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 |
225
227
 
226
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.33.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
@@ -12,7 +12,7 @@ import path from 'node:path';
12
12
  import { fileURLToPath } from 'node:url';
13
13
  import { parseArgs } from 'node:util';
14
14
 
15
- import { addSession, allowedNow, allowRules, botDir, changeBot, changeSession, createBot, disallowRules, leadsOutside, readBot, refuseCodexOutside, SESSION_FIELDS, setRoleCaps, setTempApproval } from './bot.js';
15
+ import { addSession, allowedNow, allowRules, botDir, changeBot, changeSession, createBot, disallowRules, displayName, leadsOutside, readBot, refuseCodexOutside, SESSION_FIELDS, setRoleCaps, setTempApproval } from './bot.js';
16
16
  import { COMMANDS } from './commands.js';
17
17
  import { addCommand, groomCommand, grooming, upCommand } from './groom.js';
18
18
  import { checkHealth, orcaSettingFindings } from './health.js';
@@ -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
  }
@@ -1932,12 +1935,17 @@ function tabLines({ bots, created, completed, rules, skills, permissions = [], t
1932
1935
  const back = one.session === undefined ? '' : ` --session ${one.session}`;
1933
1936
  lines.push(`${'paused'.padEnd(9)} ${what} left closed. Bring it back: ${shellWord(ownCli())} unpause --bots ${shellWord(bots)} --bot ${one.bot}${back}`);
1934
1937
  }
1938
+ // A window that has not loaded a bot's project gives its tabs no notice of
1939
+ // mail (#676).
1940
+ for (const one of projects.filter((project) => project.windowTrouble !== undefined)) {
1941
+ lines.push(`${'window'.padEnd(9)} ${displayName(one.bot)} its tabs may get no fleet mail until Orca's window loads its project: ${one.windowTrouble}. Try again: ${shellWord(ownCli())} up --bots ${shellWord(bots)} --bot ${one.bot}`);
1942
+ }
1935
1943
 
1936
1944
  // Last before the summary, because what a check found is about the setup the
1937
1945
  // run has just left behind rather than about any one thing it did.
1938
1946
  lines.push(...foundLines(found));
1939
1947
  // Hedged, because nothing here can see whether the window was told (#224).
1940
- if (projects.length > 0) lines.push(RELOAD_LINE);
1948
+ if (projects.some((project) => project.change !== undefined)) lines.push(RELOAD_LINE);
1941
1949
  lines.push(summary);
1942
1950
  return lines;
1943
1951
  }
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,19 +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';
22
+ import { setTimeout as pause } from 'node:timers/promises';
21
23
 
22
24
  import { MAILBOX_WAIT_MS, readBook, takeLineTurn, takeMailboxTurn, TYPING_HELD, TYPING_WAIT_MS } from './book.js';
23
25
  import { botDir, botNames, readBot } from './bot.js';
24
26
  import { harnessOf, isAddressOf, kitFolders, ownCli, reachesMail, SHELL_ENV, shellWord } from './launch.js';
25
- import { ackMailbox, coordinatorOf, 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';
26
28
 
27
29
  /**
28
30
  * How much of a message travels as itself. Above this it is written to a file
@@ -88,11 +90,16 @@ export function findSession(bots, target) {
88
90
  */
89
91
  export function roadBetween(from, to) {
90
92
  const pair = from.harness === 'claude' && to.harness === 'claude' && from.approval === to.approval;
91
- 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
+ }
92
99
 
93
100
  return {
94
101
  transport: 'orca',
95
- address: to.mailbox === undefined ? undefined : `run:${to.mailbox}`,
102
+ address: mailbox,
96
103
  // A Claude pair that the native road cannot carry, because the receiver is
97
104
  // running under no name the kit gave it: it was started before the kit
98
105
  // named sessions, or before it gave them names of their own (#286), and
@@ -103,6 +110,38 @@ export function roadBetween(from, to) {
103
110
  };
104
111
  }
105
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
+
106
145
  /** What `obk message to` answers: the road, the address, and who is at each end. */
107
146
  export function lookUp(bots, { to: target, from: sender, tab }) {
108
147
  const from = whoIsWriting(bots, sender, tab);
@@ -161,7 +200,7 @@ export async function sendMessage(bots, { to: target, from: sender, tab, subject
161
200
 
162
201
  const written = bodyOf(bots, { from, to, subject, text, textFile });
163
202
  // Looked at before the post, which is the send's last call to Orca (PRD 6.9).
164
- const shown = loaded(to);
203
+ const shown = await loaded(to);
165
204
  // The Escape goes first, so the turn has ended when the letter is in.
166
205
  const interrupted = interrupt ? interruptBusy(to) : {};
167
206
  const message = postMessage({
@@ -189,20 +228,50 @@ export async function sendMessage(bots, { to: target, from: sender, tab, subject
189
228
  * and lists one it has not as orphaned, as after a window restart (#579; tech
190
229
  * notes, section 1). So an orphaned tab is said, with its handle, and a
191
230
  * listing that cannot be read is said too, rather than taken for a loaded tab.
192
- * The look is made once: a tab orphaned after the send is not seen here.
231
+ * An orphaned tab of a project the window has never fetched is loaded by
232
+ * `loadInWindow`, which changes nothing on screen, so the kit asks for that
233
+ * and looks again until LOOK_MS after the ask (#676). A tab it does not load,
234
+ * as after a window restart (#579), is said as before. This is all before the
235
+ * send: a tab orphaned after it is not seen here.
193
236
  */
194
- function loaded(to) {
237
+ async function loaded(to) {
195
238
  if (to.tab === undefined) return {};
196
- let listed;
239
+ let tab;
197
240
  try {
198
- listed = tabs(to.home, { timeoutMs: LOOK_MS });
241
+ tab = tabs(to.home, { timeoutMs: LOOK_MS }).find((one) => one.tabId === to.tab);
199
242
  } catch (error) {
200
243
  return { loadTrouble: error.message };
201
244
  }
202
- const tab = listed.find((one) => one.tabId === to.tab);
203
- return tab?.orphaned === true ? { orphaned: true, handle: tab.handle } : {};
245
+ if (tab?.orphaned !== true) return {};
246
+ return (await loadedNow(to.home, tab.handle)) ? {} : { orphaned: true, handle: tab.handle };
204
247
  }
205
248
 
249
+ /**
250
+ * Whether the window loads the orphaned tab `handle` at `home` once asked to.
251
+ * The ask and every look after it keep to one deadline, LOOK_MS from the ask,
252
+ * and each Orca call gets only the time that is left, so a slow Orca cannot
253
+ * hold the send up for longer (#676). A look that fails or runs out counts as
254
+ * a tab still orphaned.
255
+ */
256
+ async function loadedNow(home, handle) {
257
+ const until = Date.now() + LOOK_MS;
258
+ const left = () => until - Date.now();
259
+ loadInWindow(home, { timeoutMs: LOOK_MS });
260
+ try {
261
+ for (let ms = left(); ms > 0; ms = left()) {
262
+ if (!listedOrphaned(home, handle, { timeoutMs: ms })) return true;
263
+ const wait = Math.min(RELOOK_MS, left());
264
+ if (wait > 0) await pause(wait);
265
+ }
266
+ } catch {
267
+ // A look that fails or runs out leaves the tab said as orphaned.
268
+ }
269
+ return false;
270
+ }
271
+
272
+ /** How long the kit waits between two looks at a tab it asked the window to load: it was loaded within 0.4 s, live (#676). */
273
+ const RELOOK_MS = 200;
274
+
206
275
  /**
207
276
  * What is waiting for a session, and the reading of it.
208
277
  *
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])
@@ -347,6 +398,29 @@ const CLIENT_KILL_MS = 3000;
347
398
  */
348
399
  export const tellWindow = (projectId) => askRuntime('project.update', { projectId, updates: {} }) !== undefined;
349
400
 
401
+ /**
402
+ * Have Orca's window load the tabs of the Orca project at `home`: undefined
403
+ * when Orca took the call, or what went wrong.
404
+ *
405
+ * The window loads a project's tabs only once it has fetched the project's
406
+ * worktrees, and nothing fetches them for a project the kit made, so its tabs
407
+ * stay orphaned and get no notice of mail (#676, tech notes). `worktree set`
408
+ * with no field writes an empty change and has Orca send the window the event
409
+ * that fetches them: nothing the user sees changes. What went wrong is said
410
+ * without Orca's own words, which name worktrees (PRD KIT-22: the kit says
411
+ * "Orca project"). `options` are `orca`'s.
412
+ */
413
+ export function loadInWindow(home, options) {
414
+ try {
415
+ orca(['worktree', 'set', '--worktree', `path:${home}`], options);
416
+ return undefined;
417
+ } catch (error) {
418
+ return typeof error.code === 'string' && !/worktree/i.test(error.code)
419
+ ? `Orca refused the call (${error.code})`
420
+ : 'Orca gave no answer the kit could read';
421
+ }
422
+ }
423
+
350
424
  /**
351
425
  * One call to Orca's runtime for a method its CLI does not offer, and the
352
426
  * runtime's answer, or undefined when there is none to be had.
@@ -433,6 +507,14 @@ export const tabs = (home, options) => orca(['terminal', 'list', '--worktree', `
433
507
  ? { ...tab, tabId: orca(['terminal', 'show', '--terminal', tab.handle], options).terminal.tabId }
434
508
  : tab));
435
509
 
510
+ /**
511
+ * Whether Orca lists the terminal `handle` at `home` as orphaned, from one
512
+ * listing and no `terminal show`, so a caller with a deadline can give the one
513
+ * call all the time it has left (#676).
514
+ */
515
+ export const listedOrphaned = (home, handle, options) => orca(['terminal', 'list', '--worktree', `path:${home}`], options).terminals
516
+ .some((tab) => tab.handle === handle && tab.orphaned === true);
517
+
436
518
  /**
437
519
  * Open a tab in the Orca project at `home`, titled `title`.
438
520
  *
@@ -1017,9 +1099,25 @@ export function wordsOfProcess(pid) {
1017
1099
  return asked.stdout.trim().split(/\s+/);
1018
1100
  }
1019
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
+
1020
1119
  /** One process as `ps` gives it, read only: `{ ppid, tpgid, comm }`, `{ late: true }` past `readMs`, or undefined. */
1021
- function psLine(pid, readMs) {
1022
- 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)) {
1023
1121
  if (asked.error?.code === 'ETIMEDOUT') return { late: true };
1024
1122
  if (asked.error || asked.status !== 0) return undefined;
1025
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, 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';
@@ -40,7 +40,9 @@ const SECOND_LOOK_MS = 2000;
40
40
  * Bring bots up in Orca. Returns `{ tabs, rules }`: one tab entry per tab it
41
41
  * looked at — the sessions the book knows, and, for Bot Father, whatever else
42
42
  * is open in its project — and one rules entry and one skills entry per bot.
43
- * `projects` has one entry per Orca project this run made or renamed.
43
+ * `projects` has one entry per Orca project this run made or renamed, and per
44
+ * project Orca's window may not have loaded, with what went wrong as
45
+ * `windowTrouble` (#676).
44
46
  *
45
47
  * With no name it is every bot in the folder, in name order. `bot` brings up
46
48
  * one bot and `session` one of its sessions, for a caller that wants one thing
@@ -149,12 +151,14 @@ export function prepareBots(bots, names, onlySession) {
149
151
  // and a bot folder the kit cannot write it into stops the run with nothing
150
152
  // opened anywhere. Only the harnesses a bot actually runs on; a bot with no
151
153
  // sessions gets none. The hooks of commands the kit no longer has go from
152
- // 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).
153
157
  for (const { bot, home } of running) {
154
- dropRemovedHooks(home);
155
158
  for (const harness of new Set(awake(sessionsOf(bot, onlySession)).map((session) => harnessOf(session, bot.harness)))) {
156
159
  installHook(home, harness, { bots, bot: bot.name });
157
160
  }
161
+ dropRemovedHooks(home);
158
162
  }
159
163
 
160
164
  // And the permission rules the user allowed, beside the hook and for the same
@@ -238,6 +242,10 @@ async function bringUpBot(bots, home, bot, onlySession) {
238
242
  // Orca's window does not see a project made or renamed on the command line
239
243
  // until it is told (#224).
240
244
  if (change !== undefined) tellWindow(orca.project);
245
+ // Its tabs get no notice of mail until the window loads them, and the window
246
+ // does not load a project it has never fetched (#676). Every run asks, so a
247
+ // project made before this is loaded too; the call changes nothing on screen.
248
+ const windowTrouble = loadInWindow(home);
241
249
 
242
250
  const live = new Map(tabs(home).map((tab) => [tab.tabId, tab]));
243
251
  const report = [];
@@ -256,7 +264,13 @@ async function bringUpBot(bots, home, bot, onlySession) {
256
264
  : spare.map((tab) => entry(tab, { bot: name, name: null, created: false }))));
257
265
  }
258
266
 
259
- return { tabs: report, project: change === undefined ? undefined : { bot: name, project: orca.project, change } };
267
+ const project = {
268
+ bot: name,
269
+ project: orca.project,
270
+ ...(change === undefined ? {} : { change }),
271
+ ...(windowTrouble === undefined ? {} : { windowTrouble }),
272
+ };
273
+ return { tabs: report, project: change === undefined && windowTrouble === undefined ? undefined : project };
260
274
  }
261
275
 
262
276
  async function bringUpSession(bots, home, live, session, bot, title) {
@@ -421,7 +435,7 @@ async function bringUpSession(bots, home, live, session, bot, title) {
421
435
  // 0.157.1 (#226). So it is given one line, through the same gate as a mail
422
436
  // notice, which the owner allowed. A fresh start's first turn is its prompt.
423
437
  const list = harness === 'codex' && launch.resume !== undefined && tui.running === true
424
- ? await typeListLine(home, session.name, made.tabId)
438
+ ? await typeListLine(home, session.name, made.tabId, { line: listLine(bots) })
425
439
  : undefined;
426
440
 
427
441
  return entry(made, {
@@ -491,8 +505,15 @@ async function shellNotReady(handle) {
491
505
  : `the shell is asking: ${last.trim()} (${wait})`;
492
506
  }
493
507
 
494
- /** The line a resumed Codex session is typed so Orca lists it, in the architect's words (#226). */
495
- 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".';
496
517
 
497
518
  /**
498
519
  * How long a resumed Codex tab is given to be one the kit may type into: live,
@@ -504,15 +525,15 @@ const LIST_LOOK_MS = 2000;
504
525
  const LIST_ASK_MS = 500;
505
526
 
506
527
  /**
507
- * Type `line`, LIST_LINE unless told otherwise, into the tab `tabId` of
508
- * `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:
509
530
  * `{ typed: true }`, or `{ typed: false, why }` with the gate's last reason
510
531
  * once LIST_WAIT_MS is up. Nothing goes into a question, a form or a menu
511
532
  * (#329, #416), nor into a tab the kit cannot tell about. Each look and its
512
533
  * line are taken in the session's turn for a line (#482), unless the caller
513
534
  * says it holds the session's typing turn already (`held`).
514
535
  */
515
- export async function typeListLine(home, session, tabId, { line = LIST_LINE, held = false } = {}) {
536
+ export async function typeListLine(home, session, tabId, { line, held = false }) {
516
537
  const until = Date.now() + LIST_WAIT_MS;
517
538
  for (;;) {
518
539
  let turn;
@@ -914,9 +935,15 @@ const setAside = (entry, which) => (which.ranOn === undefined
914
935
  * all, so no tab can be opened in it.
915
936
  */
916
937
  function orcaProject(home, title) {
917
- const found = findProject(home);
918
- if (found === undefined) return ids(makeProject(home, title), 'made');
919
- 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
+ }
920
947
  }
921
948
 
922
949
  const ids = (setup, change) => ({ project: setup.projectId, setup: setup.id, change });