@assuredloop/orca-bot-kit 0.34.0 → 0.36.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,27 @@ 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
+
406
+ Under an Orca Claude account, the kit's line for a Claude session also carries
407
+ `--settings` with one key, `autoMemoryDirectory`. It names the physical folder
408
+ behind the account's linked `projects` folder, so a session saves its memory
409
+ with no permission dialog. The memory is the same memory, and the kit writes no
410
+ file for this. A bot, its repository or an account that names its own
411
+ `autoMemoryDirectory` keeps it. A conversation that began before this keeps its old path until the
412
+ session restarts under this version and then begins a new conversation with
413
+ `/clear`. A restart alone or a `/clear` alone does not change the path. After
414
+ Orca brings a tab back by itself, a `/clear` before a
415
+ restart brings the dialog back; `obk health` names such a session
416
+ ([ADR 0054](docs/adr/0054-the-kits-launch-line-names-the-physical-memory-folder.md)).
417
+
397
418
  So: kill a tab, or reboot, and `obk up` brings the session back with the
398
419
  conversation it was having, rather than starting a new one. Clear a session —
399
420
  `/clear` on Claude Code, `/new` on Codex — and the kit writes down the new id,
@@ -616,7 +637,7 @@ verdict: which of these matter, and in what order, is for you or for Bot Father.
616
637
  ## Sessions and bots that talk
617
638
 
618
639
  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)):
640
+ address ([ADR 0053](docs/adr/0053-messaging-transport.md)):
620
641
 
621
642
  ```sh
622
643
  obk message to --bots /path/to/my-bots --to api-bot/daily
@@ -640,6 +661,13 @@ token, `<bot>.<session>`: `message to` answers the mailbox road for it, never
640
661
  that name, until the kit's launch line next starts it (`obk restart`, or
641
662
  `obk up` after its tab is gone), which gives it a token name of its own.
642
663
 
664
+ It also includes a Claude session that the sender's own Claude Code does not
665
+ list as live. Claude Code's messaging reaches only the sessions in its own
666
+ registry, under its config folder (`CLAUDE_CONFIG_DIR`, or `~/.claude`). After
667
+ a Claude account switch, Orca starts new tabs under a config folder of its own,
668
+ so a session started before the switch and one started after it cannot reach
669
+ each other that way. `message to` and `message send` then take the mailbox.
670
+
643
671
  A session's mailbox is made the first time the kit starts it, and written in
644
672
  the book beside its tab. It is an Orca Run rather than the session's tab,
645
673
  because a tab's address dies with the tab and a Run does not — so a message sent
@@ -669,7 +697,7 @@ keeps a command it was running as a background terminal after an Escape. A
669
697
  receiver reads its mail with `obk message check`, which marks it read: a bare
670
698
  `orca orchestration check` leaves it unread, and Orca then gives no more
671
699
  notices for that mailbox
672
- ([ADR 0042](docs/adr/0042-messaging-transport.md)).
700
+ ([ADR 0053](docs/adr/0053-messaging-transport.md)).
673
701
 
674
702
  A message longer than 4 KiB is written to a file beside your bots folder and
675
703
  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.36.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
  }
@@ -20,7 +20,7 @@ import { eachLine, firstLine } from './lines.js';
20
20
  const claudeDir = (home) => path.join(homedir(), '.claude', 'projects', slug(home));
21
21
 
22
22
  /** The folder's name is the working directory with everything else turned into a dash. */
23
- const slug = (home) => home.replaceAll(/[^A-Za-z0-9]/g, '-');
23
+ export const slug = (home) => home.replaceAll(/[^A-Za-z0-9]/g, '-');
24
24
 
25
25
  /**
26
26
  * Pi's sessions: one folder per working directory, named from its real path
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
  *
@@ -294,11 +294,12 @@ const NO_DAEMON = '--no-daemon';
294
294
 
295
295
  // A string is shell text, so it is read as the shell will split it: `'--no-daemon'`
296
296
  // is the flag, and `'/tmp/a --no-daemon b'` is one path that merely says it.
297
- const setsNoDaemon = (session) => {
298
- const extra = session.extra_args;
299
- const words = Array.isArray(extra) ? extra.map(String) : set(extra) ? shellWords(String(extra)) : [];
300
- return words.includes(NO_DAEMON);
301
- };
297
+ const extraArgWords = (extra) => (Array.isArray(extra) ? extra.map(String) : set(extra) ? shellWords(String(extra)) : []);
298
+
299
+ const setsNoDaemon = (session) => extraArgWords(session.extra_args).includes(NO_DAEMON);
300
+
301
+ /** Whether the session's own extra arguments hold Claude Code's `--settings`. */
302
+ const setsSettings = (session) => extraArgWords(session.extra_args).some((word) => word === '--settings' || word.startsWith('--settings='));
302
303
 
303
304
  /**
304
305
  * The words a shell makes of `text`: split on blanks outside quotes, quotes
@@ -412,7 +413,7 @@ const where = (session) => (set(session.name) ? `session ${session.name}` : 'thi
412
413
  * send would land on that list and answer it. A prompt given as an argument is
413
414
  * held by the harness until it is ready for it (tech notes, section 1).
414
415
  */
415
- export function launchCommand(session, { harness, home, workDir, prompt, promptFile: fromFile, resume, address, kit, sessionId, name }) {
416
+ export function launchCommand(session, { harness, home, workDir, prompt, promptFile: fromFile, resume, address, kit, sessionId, name, memoryFolder }) {
416
417
  const trouble = sessionTrouble(session, harness, home);
417
418
  if (trouble !== undefined) throw new Error(trouble);
418
419
  // Pi is told which conversation it is, a new one too: the kit names it, and
@@ -435,11 +436,15 @@ export function launchCommand(session, { harness, home, workDir, prompt, promptF
435
436
 
436
437
  if (harness === 'claude') {
437
438
  // The name is the address other Claude sessions write to, so it goes on
438
- // every launch line, a resume included (ADR 0042).
439
+ // every launch line, a resume included (ADR 0053).
439
440
  if (address !== undefined) words.push('-n', address);
440
441
  // The context window rides on the model name: `sonnet[1m]`.
441
442
  if (set(session.model)) words.push('--model', set(session.context) ? `${session.model}[${session.context}]` : session.model);
442
443
  if (set(session.effort)) words.push('--effort', String(session.effort));
444
+ // This one key and no other: `--settings` can carry rules, folders and
445
+ // hooks, which is why a user's extra argument may not use it (ADR 0054,
446
+ // ADR 0049). A hand-written one in bot.yaml is the user's, and wins.
447
+ if (memoryFolder !== undefined && !setsSettings(session)) words.push('--settings', JSON.stringify({ autoMemoryDirectory: memoryFolder }));
443
448
  } else if (harness === 'pi') {
444
449
  // Pi trusts none of the folder's own files (`.pi/`, `.agents/skills`), so it
445
450
  // shows no trust screen and runs nothing the kit has not looked at (#578).
package/src/memory.js ADDED
@@ -0,0 +1,113 @@
1
+ // The physical memory folder of a Claude session, when the path Claude Code
2
+ // would use leads through a link (#722, ADR 0054).
3
+ //
4
+ // Orca starts a Claude tab with CLAUDE_CONFIG_DIR at a profile of its own,
5
+ // where `projects` is a link to `~/.claude/projects`. Claude Code names its
6
+ // memory folder through that link, and a write there stops on a dialog that
7
+ // only a person can answer (tech notes, section 2). Named by its real path, the
8
+ // same folder has no dialog. Whenever the answer is not sure, there is no
9
+ // answer, and the session runs as it did: a dialog costs a wait, and a wrong
10
+ // folder costs the session its memory.
11
+
12
+ import { spawnSync } from 'node:child_process';
13
+ import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from 'node:fs';
14
+ import path from 'node:path';
15
+
16
+ import { slug } from './conversations.js';
17
+ import { selectedClaudeProfile } from './orca.js';
18
+
19
+ /** The longest slug Claude Code uses as it is. Past this it adds a hash of its own. */
20
+ const LONGEST_SLUG = 200;
21
+
22
+ /**
23
+ * The folder to name for a session of the bot at `home`, or undefined when the
24
+ * kit should name none: no profile is routed, its `projects` is no link, the
25
+ * bot or the profile names a folder of its own, the project root is not sure,
26
+ * or the slug is too long.
27
+ */
28
+ export function physicalMemoryFolder(home) {
29
+ const profile = selectedClaudeProfile();
30
+ if (profile === undefined) return undefined;
31
+ const projects = linkedFolder(path.join(profile, 'projects'));
32
+ if (projects === undefined) return undefined;
33
+ const root = projectRoot(home);
34
+ if (root === undefined) return undefined;
35
+ // Claude Code reads a project's local settings from the top of its repository as well as from the bot's folder.
36
+ const named = [
37
+ path.join(home, '.claude', 'settings.json'),
38
+ path.join(home, '.claude', 'settings.local.json'),
39
+ path.join(root, '.claude', 'settings.local.json'),
40
+ path.join(profile, 'settings.json'),
41
+ ];
42
+ if (named.some(namesMemoryFolder)) return undefined;
43
+ // Claude Code holds its project root in NFC, whatever form the disk has.
44
+ const name = slug(root.normalize('NFC'));
45
+ return name.length > LONGEST_SLUG ? undefined : path.join(projects, name, 'memory');
46
+ }
47
+
48
+ /** The real path of the folder the link `entry` leads to, or undefined when it is not such a link. */
49
+ function linkedFolder(entry) {
50
+ try {
51
+ if (!lstatSync(entry).isSymbolicLink()) return undefined;
52
+ return statSync(entry).isDirectory() ? realpathSync(entry) : undefined;
53
+ } catch {
54
+ return undefined;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Whether the settings file `file` may name a memory folder of its own: it has
60
+ * the key, or it is there and cannot be read, so the kit cannot tell.
61
+ */
62
+ function namesMemoryFolder(file) {
63
+ let text;
64
+ try {
65
+ text = readFileSync(file, 'utf8');
66
+ } catch (error) {
67
+ return error.code !== 'ENOENT' && error.code !== 'ENOTDIR';
68
+ }
69
+ try {
70
+ const settings = JSON.parse(text);
71
+ return settings !== null && typeof settings === 'object' && Object.hasOwn(settings, 'autoMemoryDirectory');
72
+ } catch {
73
+ return true;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * The folder Claude Code keys memory by: the top of the git repository that
79
+ * holds `home`, the main repository's for a linked worktree, since every
80
+ * worktree of a repository shares one memory (tech notes, section 2). With no
81
+ * repository, it is `home` itself. Undefined when the kit is not sure: a `.git`
82
+ * is on the way and git cannot say what it is, a linked worktree's shared git
83
+ * folder is not named `.git`, or the `.git` at the top is a link.
84
+ */
85
+ function projectRoot(home) {
86
+ const shared = revParse(home, '--git-common-dir');
87
+ if (shared === undefined) return gitOnTheWay(home) ? undefined : realpathSync(home);
88
+ const own = revParse(home, '--git-dir');
89
+ if (own === undefined) return undefined;
90
+ const main = realpathSync(path.resolve(home, shared));
91
+ const linked = realpathSync(path.resolve(home, own)) !== main;
92
+ // Only a linked worktree tells its main repository by the shared git folder:
93
+ // `--separate-git-dir` puts a repository's own git folder away from its top.
94
+ const top = !linked ? revParse(home, '--show-toplevel') : path.basename(main) === '.git' ? path.dirname(main) : undefined;
95
+ if (top === undefined) return undefined;
96
+ return lstatSync(path.join(top, '.git'), { throwIfNoEntry: false })?.isSymbolicLink() ? undefined : top;
97
+ }
98
+
99
+ /** Whether `folder` or a folder above it holds a `.git` entry. */
100
+ function gitOnTheWay(folder) {
101
+ for (let at = realpathSync(folder); ; at = path.dirname(at)) {
102
+ if (existsSync(path.join(at, '.git'))) return true;
103
+ if (path.dirname(at) === at) return false;
104
+ }
105
+ }
106
+
107
+ /** One answer of `git rev-parse` in `folder`, or undefined when git gives none. */
108
+ function revParse(folder, question) {
109
+ const asked = spawnSync('git', ['-C', folder, 'rev-parse', question], { encoding: 'utf8' });
110
+ // Only git's record terminator comes off: trailing whitespace may be part of the folder's name.
111
+ const answer = (asked.stdout ?? '').replace(/\n$/, '');
112
+ return asked.status === 0 && answer !== '' ? answer : undefined;
113
+ }
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';
@@ -146,10 +146,25 @@ export function findProject(home) {
146
146
  * names its CLI: a build's folder is written only inside its own CLI, so the
147
147
  * kit cannot tell it from the app (#551).
148
148
  */
149
- export const profilesDir = () => path.join(
150
- process.env.OBK_ORCA_DATA || path.join(homedir(), 'Library', 'Application Support', 'orca'),
151
- 'profiles',
152
- );
149
+ export const profilesDir = () => path.join(orcaData(), 'profiles');
150
+
151
+ const orcaData = () => process.env.OBK_ORCA_DATA || path.join(homedir(), 'Library', 'Application Support', 'orca');
152
+
153
+ /**
154
+ * The home of the Claude profile Orca routes a new Claude tab to, or undefined
155
+ * when it routes none. Orca writes the path to `selected-host`, with nothing
156
+ * in it when no profile is routed, and removes the file when it has no accounts
157
+ * (read in the bundle of Orca 1.4.245, tech notes, section 2).
158
+ */
159
+ export function selectedClaudeProfile() {
160
+ let home;
161
+ try {
162
+ home = readFileSync(path.join(orcaData(), 'claude-profiles', 'selected-host'), 'utf8').replace(/\r?\n$/, '');
163
+ } catch {
164
+ return undefined;
165
+ }
166
+ return path.isAbsolute(home) ? home : undefined;
167
+ }
153
168
 
154
169
  /**
155
170
  * Orca's own default launch arguments, per profile it keeps:
@@ -290,12 +305,63 @@ function argsIn(read) {
290
305
  * repo as git kind, which Orca gives no workspace at all, so no tab can be
291
306
  * created in it. `setup-update` turns it into the folder workspace a bot needs
292
307
  * and names the project in one go.
308
+ *
309
+ * Orca 1.4.236 and later register the top folder of the git repo that holds
310
+ * the folder they are given: for a bot home, the whole bots folder (#712). So
311
+ * for the time of `repo add` the home gets a `.git` of its own, which makes it
312
+ * its own top folder, and that `.git` goes straight after. One that was there
313
+ * before is the user's, and stays as it was.
293
314
  */
294
315
  export function makeProject(home, title) {
295
- const setupId = orca(['repo', 'add', '--path', home]).repo.id;
316
+ const made = gitOfItsOwn(home);
317
+ let setupId;
318
+ try {
319
+ setupId = orca(['repo', 'add', '--path', home]).repo.id;
320
+ } finally {
321
+ if (made) rmSync(path.join(home, '.git'), { recursive: true, force: true });
322
+ }
296
323
  return asFolderProject(setupId, title);
297
324
  }
298
325
 
326
+ /** The file inside a `.git` that says the kit made it for one `repo add`. */
327
+ const KIT_GIT_MARK = 'obk-made-for-orca';
328
+
329
+ /**
330
+ * Give `home` a `.git` of its own: true when this call made it, false when one
331
+ * is there already. The mkdir fails on anything already there, so the kit
332
+ * never takes over a `.git` it did not make, and the mark goes in before git
333
+ * fills it, so one left by a run that died is known as the kit's.
334
+ */
335
+ function gitOfItsOwn(home) {
336
+ const git = path.join(home, '.git');
337
+ try {
338
+ mkdirSync(git);
339
+ } catch (error) {
340
+ if (error.code === 'EEXIST') return false;
341
+ throw error;
342
+ }
343
+ try {
344
+ writeFileSync(path.join(git, KIT_GIT_MARK), 'obk made this for one `orca repo add` and takes it away after.\n');
345
+ const result = spawnSync('git', ['init', '--quiet', home], { encoding: 'utf8' });
346
+ if (result.error) throw new Error(`could not run git: ${result.error.message}`);
347
+ if (result.status !== 0) throw new Error(`git init failed in ${home}: ${(result.stderr || '').trim()}`);
348
+ } catch (error) {
349
+ rmSync(git, { recursive: true, force: true });
350
+ throw error;
351
+ }
352
+ return true;
353
+ }
354
+
355
+ /**
356
+ * Take away the `.git` that a run which died during `makeProject` left in
357
+ * `home`. Only one that holds the kit's mark: any other is the user's.
358
+ */
359
+ export function dropLeftGit(home) {
360
+ const git = path.join(home, '.git');
361
+ const stat = lstatSync(git, { throwIfNoEntry: false });
362
+ if (stat?.isDirectory() && existsSync(path.join(git, KIT_GIT_MARK))) rmSync(git, { recursive: true, force: true });
363
+ }
364
+
299
365
  /** Make an existing registration a folder workspace called `title`. */
300
366
  export function asFolderProject(setupId, title) {
301
367
  return orca(['project', 'setup-update', '--setup', setupId, '--kind', 'folder', '--display-name', title])
@@ -1048,9 +1114,25 @@ export function wordsOfProcess(pid) {
1048
1114
  return asked.stdout.trim().split(/\s+/);
1049
1115
  }
1050
1116
 
1117
+ /**
1118
+ * Whether the process `pid` runs now, as `ps` answers for it: false only when
1119
+ * `ps` says no process has that pid, and undefined when it cannot tell: `ps`
1120
+ * did not start, failed, ran out of time or printed a line it cannot read.
1121
+ * macOS's `ps` answers a pid that is not there with exit 1 and nothing on
1122
+ * stdout or stderr (seen on macOS 27.0.1), and it exits 1 on an error of its
1123
+ * own too, with the error on stderr (review of PR #702).
1124
+ */
1125
+ export function isRunning(pid, readMs) {
1126
+ const asked = askPs(pid, readMs);
1127
+ if (asked.error === undefined && asked.status === 1 && asked.stdout.trim() === '' && asked.stderr.trim() === '') return false;
1128
+ return psLine(pid, readMs, asked)?.comm === undefined ? undefined : true;
1129
+ }
1130
+
1131
+ const askPs = (pid, readMs) =>
1132
+ spawnSync(PS, ['-o', 'pid=,ppid=,tpgid=,comm=', '-p', String(pid)], { encoding: 'utf8', ...(readMs === undefined ? {} : { timeout: readMs }) });
1133
+
1051
1134
  /** 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 }) });
1135
+ function psLine(pid, readMs, asked = askPs(pid, readMs)) {
1054
1136
  if (asked.error?.code === 'ETIMEDOUT') return { late: true };
1055
1137
  if (asked.error || asked.status !== 0) return undefined;
1056
1138
  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,15 @@ 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
+ import { physicalMemoryFolder } from './memory.js';
17
18
  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';
19
+ import { addressOf, HARNESSES, harnessOf, isAddressOf, isShortPrompt, kitFolders, launchCommand, mailboxStep, ownCli, reachesMail, sessionTrouble, shellWord, startPrompt, workDirOf } from './launch.js';
20
+ 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
21
  import { TAB_ENV } from './record.js';
21
22
  import { buildAgents, rulesStamp } from './rules.js';
22
23
  import { linkSkills } from './skills.js';
@@ -151,12 +152,14 @@ export function prepareBots(bots, names, onlySession) {
151
152
  // and a bot folder the kit cannot write it into stops the run with nothing
152
153
  // opened anywhere. Only the harnesses a bot actually runs on; a bot with no
153
154
  // 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).
155
+ // every hooks file the bot has, of a harness it has left too (#613). That
156
+ // comes after the kit's own hooks, which take them out of the files they
157
+ // write anyway, so a file the kit refuses is left as the user wrote it (#708).
155
158
  for (const { bot, home } of running) {
156
- dropRemovedHooks(home);
157
159
  for (const harness of new Set(awake(sessionsOf(bot, onlySession)).map((session) => harnessOf(session, bot.harness)))) {
158
160
  installHook(home, harness, { bots, bot: bot.name });
159
161
  }
162
+ dropRemovedHooks(home);
160
163
  }
161
164
 
162
165
  // And the permission rules the user allowed, beside the hook and for the same
@@ -433,7 +436,7 @@ async function bringUpSession(bots, home, live, session, bot, title) {
433
436
  // 0.157.1 (#226). So it is given one line, through the same gate as a mail
434
437
  // notice, which the owner allowed. A fresh start's first turn is its prompt.
435
438
  const list = harness === 'codex' && launch.resume !== undefined && tui.running === true
436
- ? await typeListLine(home, session.name, made.tabId)
439
+ ? await typeListLine(home, session.name, made.tabId, { line: listLine(bots) })
437
440
  : undefined;
438
441
 
439
442
  return entry(made, {
@@ -503,8 +506,15 @@ async function shellNotReady(handle) {
503
506
  : `the shell is asking: ${last.trim()} (${wait})`;
504
507
  }
505
508
 
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.';
509
+ /**
510
+ * The line a resumed Codex session is typed so Orca lists it, in the
511
+ * architect's words (#226). It names the kit's mail check as the bot's rules
512
+ * do, because a restart often comes for mail that is waiting (#630), and a line
513
+ * that asked for "ok" alone had the session drop the mail it had just read (#709).
514
+ */
515
+ const listLine = (bots) => 'obk: this session was resumed in a new tab, and the kit types this line so Orca lists it.'
516
+ + ` Read your mail with \`${shellWord(ownCli())} message check --bots ${shellWord(bots)}\`, and do what it asks.`
517
+ + ' That includes mail you read just before this line. If there is none, reply "ok".';
508
518
 
509
519
  /**
510
520
  * How long a resumed Codex tab is given to be one the kit may type into: live,
@@ -516,15 +526,15 @@ const LIST_LOOK_MS = 2000;
516
526
  const LIST_ASK_MS = 500;
517
527
 
518
528
  /**
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:
529
+ * Type `line` into the tab `tabId` of `session` once the gate every typed
530
+ * line goes through lets it:
521
531
  * `{ typed: true }`, or `{ typed: false, why }` with the gate's last reason
522
532
  * once LIST_WAIT_MS is up. Nothing goes into a question, a form or a menu
523
533
  * (#329, #416), nor into a tab the kit cannot tell about. Each look and its
524
534
  * line are taken in the session's turn for a line (#482), unless the caller
525
535
  * says it holds the session's typing turn already (`held`).
526
536
  */
527
- export async function typeListLine(home, session, tabId, { line = LIST_LINE, held = false } = {}) {
537
+ export async function typeListLine(home, session, tabId, { line, held = false }) {
528
538
  const until = Date.now() + LIST_WAIT_MS;
529
539
  for (;;) {
530
540
  let turn;
@@ -601,6 +611,7 @@ function launchFor(bots, bot, session, harness, home, workDir, resume, held, fre
601
611
  resume,
602
612
  address,
603
613
  kit: kitFolders(bots, bot.name),
614
+ ...(harness === 'claude' ? { memoryFolder: physicalMemoryFolder(home) } : {}),
604
615
  // A new Pi conversation's id, and the name its tab and session list show,
605
616
  // `<bot>.<session>` as a Codex thread is named (#480).
606
617
  ...(harness === 'pi' ? { sessionId: resume === undefined ? fresh : undefined, name: `${bot.name}.${session.name}` } : {}),
@@ -926,9 +937,15 @@ const setAside = (entry, which) => (which.ranOn === undefined
926
937
  * all, so no tab can be opened in it.
927
938
  */
928
939
  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');
940
+ const turn = takeProjectTurn(home);
941
+ try {
942
+ dropLeftGit(home);
943
+ const found = findProject(home);
944
+ if (found === undefined) return ids(makeProject(home, title), 'made');
945
+ return found.kind === 'folder' ? ids(found) : ids(asFolderProject(found.id, title), 'renamed');
946
+ } finally {
947
+ turn.release();
948
+ }
932
949
  }
933
950
 
934
951
  const ids = (setup, change) => ({ project: setup.projectId, setup: setup.id, change });