@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 +30 -2
- package/SETUP.md +1 -1
- package/package.json +1 -1
- package/skills/obk-bot-building/SKILL.md +3 -2
- package/src/book.js +17 -0
- package/src/clear.js +16 -9
- package/src/cli.js +3 -0
- package/src/conversations.js +1 -1
- package/src/hooks.js +25 -1
- package/src/launch.js +13 -8
- package/src/memory.js +113 -0
- package/src/message.js +44 -6
- package/src/name.js +37 -3
- package/src/orca.js +90 -8
- package/src/permissions.js +1 -1
- package/src/retire.js +1 -1
- package/src/sent.js +16 -8
- package/src/temp.js +41 -13
- package/src/up.js +31 -14
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
|
|
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
|
|
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.
|
|
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
|
@@ -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.
|
|
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.
|
|
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 = (
|
|
303
|
+
const typeKey = (keys) => {
|
|
301
304
|
try {
|
|
302
|
-
send(handle,
|
|
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 +=
|
|
310
|
+
if (error.code === TIMED_OUT && (it.harness === 'codex' || it.harness === 'pi')) typed += [...keys].length;
|
|
308
311
|
throw error;
|
|
309
312
|
}
|
|
310
|
-
typed +=
|
|
313
|
+
typed += [...keys].length;
|
|
311
314
|
};
|
|
312
315
|
try {
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
+
// Whole, a write Orca's own notice cannot land inside (#713).
|
|
317
|
+
if (whole) typeKey(command);
|
|
318
|
+
else {
|
|
319
|
+
for (const char of command) {
|
|
320
|
+
if (typed > 0) await lookAgain();
|
|
321
|
+
typeKey(char);
|
|
322
|
+
}
|
|
316
323
|
}
|
|
317
324
|
await lookAgain();
|
|
318
325
|
let wrong = await typedWrong(handle, it.harness, command, version, check);
|
package/src/cli.js
CHANGED
|
@@ -1319,6 +1319,9 @@ function toLines(answer, bots, where) {
|
|
|
1319
1319
|
...(answer.unnamed === true
|
|
1320
1320
|
? [` ${where} is a Claude session running under no name the kit gave it: it was started before the kit named sessions, and nothing renames a live harness. It gets one the next time it starts. Until then the mailbox is the road that reaches it.`]
|
|
1321
1321
|
: []),
|
|
1322
|
+
...(answer.unlisted === true
|
|
1323
|
+
? [` ${where} is a Claude session of your approval class, but your own Claude Code does not list it among the live sessions its messaging reaches: it runs under another Claude config folder, or it is not running. So the mailbox is the road that reaches it.`]
|
|
1324
|
+
: []),
|
|
1322
1325
|
` Send it: ${shellWord(ownCli())} message send --bots ${shellWord(bots)} --to ${shellWord(where)} --subject <text> --text <text>`,
|
|
1323
1326
|
];
|
|
1324
1327
|
}
|
package/src/conversations.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
151
|
-
|
|
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
|
|
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);
|
package/src/permissions.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
12
|
-
// Code 2.1.
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
// `
|
|
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
|
-
|
|
32
|
-
|
|
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)
|
|
574
|
-
*
|
|
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
|
|
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) => !
|
|
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 (
|
|
645
|
-
const moved =
|
|
646
|
-
if (moved >= 0) return `the row ${
|
|
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
|
-
/**
|
|
507
|
-
|
|
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
|
|
520
|
-
*
|
|
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
|
|
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
|
|
930
|
-
|
|
931
|
-
|
|
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 });
|