@assuredloop/orca-bot-kit 0.33.0 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -2
- package/SETUP.md +4 -2
- 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 +10 -2
- package/src/hooks.js +25 -1
- package/src/launch.js +2 -2
- package/src/message.js +82 -13
- package/src/name.js +37 -3
- package/src/orca.js +102 -4
- 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 +43 -16
package/README.md
CHANGED
|
@@ -394,6 +394,15 @@ address that is none of your bots folder's sessions, the hook tells it so, and
|
|
|
394
394
|
names the address `obk message to` would have given. The message goes either
|
|
395
395
|
way ([ADR 0032](docs/adr/0032-a-native-message-outside-the-fleet-is-warned-about.md)).
|
|
396
396
|
|
|
397
|
+
A Claude bot's `.claude/settings.json` also gets
|
|
398
|
+
`"skillOverrides": { "auto-mode-setup": "off" }`, so no session of the bot stops
|
|
399
|
+
on Claude Code's `Teach auto mode about your environment?` form. Your other
|
|
400
|
+
skill overrides are kept. The cost: `/auto-mode-setup` is off in that bot's
|
|
401
|
+
sessions, and your own sessions outside the bots folder keep it. To use it in a
|
|
402
|
+
bot's sessions, set `"auto-mode-setup": "on"` under `skillOverrides` in the bot's
|
|
403
|
+
`.claude/settings.local.json`, which wins over the kit's entry. The kit never
|
|
404
|
+
writes that file.
|
|
405
|
+
|
|
397
406
|
So: kill a tab, or reboot, and `obk up` brings the session back with the
|
|
398
407
|
conversation it was having, rather than starting a new one. Clear a session —
|
|
399
408
|
`/clear` on Claude Code, `/new` on Codex — and the kit writes down the new id,
|
|
@@ -616,7 +625,7 @@ verdict: which of these matter, and in what order, is for you or for Bot Father.
|
|
|
616
625
|
## Sessions and bots that talk
|
|
617
626
|
|
|
618
627
|
Ask the kit how to reach a session, and it answers with the road and the
|
|
619
|
-
address ([ADR
|
|
628
|
+
address ([ADR 0053](docs/adr/0053-messaging-transport.md)):
|
|
620
629
|
|
|
621
630
|
```sh
|
|
622
631
|
obk message to --bots /path/to/my-bots --to api-bot/daily
|
|
@@ -640,6 +649,13 @@ token, `<bot>.<session>`: `message to` answers the mailbox road for it, never
|
|
|
640
649
|
that name, until the kit's launch line next starts it (`obk restart`, or
|
|
641
650
|
`obk up` after its tab is gone), which gives it a token name of its own.
|
|
642
651
|
|
|
652
|
+
It also includes a Claude session that the sender's own Claude Code does not
|
|
653
|
+
list as live. Claude Code's messaging reaches only the sessions in its own
|
|
654
|
+
registry, under its config folder (`CLAUDE_CONFIG_DIR`, or `~/.claude`). After
|
|
655
|
+
a Claude account switch, Orca starts new tabs under a config folder of its own,
|
|
656
|
+
so a session started before the switch and one started after it cannot reach
|
|
657
|
+
each other that way. `message to` and `message send` then take the mailbox.
|
|
658
|
+
|
|
643
659
|
A session's mailbox is made the first time the kit starts it, and written in
|
|
644
660
|
the book beside its tab. It is an Orca Run rather than the session's tab,
|
|
645
661
|
because a tab's address dies with the tab and a Run does not — so a message sent
|
|
@@ -669,7 +685,7 @@ keeps a command it was running as a background terminal after an Escape. A
|
|
|
669
685
|
receiver reads its mail with `obk message check`, which marks it read: a bare
|
|
670
686
|
`orca orchestration check` leaves it unread, and Orca then gives no more
|
|
671
687
|
notices for that mailbox
|
|
672
|
-
([ADR
|
|
688
|
+
([ADR 0053](docs/adr/0053-messaging-transport.md)).
|
|
673
689
|
|
|
674
690
|
A message longer than 4 KiB is written to a file beside your bots folder and
|
|
675
691
|
named in the message, so a long one arrives whole without landing a document in
|
package/SETUP.md
CHANGED
|
@@ -126,7 +126,9 @@ default and the kit will not guess. It is Bot Father's own harness only; each
|
|
|
126
126
|
bot they make later chooses its own. If only one harness is installed, say so
|
|
127
127
|
and confirm it rather than asking as though the choice were open. If they choose
|
|
128
128
|
`pi`, they sign in to Pi themselves first: the kit never handles Pi's
|
|
129
|
-
credentials, and `obk init` starts nothing while Pi can use no model.
|
|
129
|
+
credentials, and `obk init` starts nothing while Pi can use no model. When you
|
|
130
|
+
ask or confirm, tell them: "On Pi, Bot Father acts without asking first, because
|
|
131
|
+
Pi has no approval step."
|
|
130
132
|
|
|
131
133
|
## 4. Make the fleet
|
|
132
134
|
|
|
@@ -220,7 +222,7 @@ answers that screen, by the command and its arguments below the table.
|
|
|
220
222
|
| Codex's `/new`: `Where should the new conversation run?` | `\r` | return: its selection starts on **Current checkout** (bot home); on 0.162.0 it is called **1. Use current Git worktree** (Keep using the current working directory). Never **Create new Git worktree** | Codex 0.160.0; Codex 0.162.0 (#516) |
|
|
221
223
|
| Codex's update offer, `1. Update now` | `\r` | return: its selection starts on **Update now**, which accepts it | Codex 0.156.1; seen on 0.157.1 (offering 0.158.0) with the selection on **Update now**, not pressed; 0.158.0 offered 0.160.0, taken on 2026-10-02 by Bot Father's restarts |
|
|
222
224
|
| `[oh-my-zsh] Would you like to update?` | `n` | they update their own shell | oh-my-zsh bf77e35 |
|
|
223
|
-
| Claude Code's `Teach auto mode about your environment?` | `obk session answer`, or `obk temp answer` for a temporary session | the kit sends Esc, which cancels it (**Not now**). On 2.1.283 and 2.1.
|
|
225
|
+
| Claude Code's `Teach auto mode about your environment?` | `obk session answer`, or `obk temp answer` for a temporary session | the kit sends Esc, which cancels it (**Not now**). On 2.1.283, 2.1.302 and 2.1.310 it is a form, not a list: `How you use Claude here`, `Also scan shell history`, `Also scan your other repos`, `Continue`, and `Enter to continue · Esc to cancel`. On 2.1.283 the foot starts `←/→ to change usage`, and on 2.1.302 and 2.1.310 it starts `←/→ to change`. Its description wraps to the tab's width, on one row or more. The values after the three settings can differ: `Also scan shell history` showed `true` on 2.1.283, and both `false` and `true` on 2.1.302. The kit sends Esc whatever the values are. **Never send a return**: Enter is **Continue**, which starts a scan of the project, recent Claude sessions and, by default, the machine's shell history | Not yet proven: Esc cancels in Claude Code 2.1.283's code, not yet seen live (#370); the form was seen live twice on 2.1.283, in #261's live test and #239's live run 1 (#416); 2.1.278 showed a numbered list, answered with 2. Not now; the 2.1.302 form was seen live twice, in #604 and in #646, where it showed `Also scan shell history` on `true` and one Esc sent by hand removed it; the 2.1.310 form was seen live in #701, its description on one row, and one Esc sent by hand removed it |
|
|
224
226
|
| Claude Code 2.1.289's `Teach auto mode about your environment?`, a numbered list: `1. Yes`, `2. Not now`, `3. Don't show again`, and `Enter to confirm · Esc to cancel`, above the input box | `obk session answer`, or `obk temp answer` for a temporary session | the kit goes down, then returns, to **2. Not now**: its selection starts on **1. Yes**. The kit reads the screen after the down arrow, and sends the return only with the selection on **2. Not now**. Never **1. Yes**, which starts the teach scan, and never **3. Don't show again** | Not yet proven: the list was seen live on 2.1.289 with its selection on 1. Yes, in #489's live run 1, and was not answered there |
|
|
225
227
|
|
|
226
228
|
Each key counts on where the selection starts, as it did on the version named.
|
package/package.json
CHANGED
|
@@ -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
|
@@ -12,7 +12,7 @@ import path from 'node:path';
|
|
|
12
12
|
import { fileURLToPath } from 'node:url';
|
|
13
13
|
import { parseArgs } from 'node:util';
|
|
14
14
|
|
|
15
|
-
import { addSession, allowedNow, allowRules, botDir, changeBot, changeSession, createBot, disallowRules, leadsOutside, readBot, refuseCodexOutside, SESSION_FIELDS, setRoleCaps, setTempApproval } from './bot.js';
|
|
15
|
+
import { addSession, allowedNow, allowRules, botDir, changeBot, changeSession, createBot, disallowRules, displayName, leadsOutside, readBot, refuseCodexOutside, SESSION_FIELDS, setRoleCaps, setTempApproval } from './bot.js';
|
|
16
16
|
import { COMMANDS } from './commands.js';
|
|
17
17
|
import { addCommand, groomCommand, grooming, upCommand } from './groom.js';
|
|
18
18
|
import { checkHealth, orcaSettingFindings } from './health.js';
|
|
@@ -1319,6 +1319,9 @@ function toLines(answer, bots, where) {
|
|
|
1319
1319
|
...(answer.unnamed === true
|
|
1320
1320
|
? [` ${where} is a Claude session running under no name the kit gave it: it was started before the kit named sessions, and nothing renames a live harness. It gets one the next time it starts. Until then the mailbox is the road that reaches it.`]
|
|
1321
1321
|
: []),
|
|
1322
|
+
...(answer.unlisted === true
|
|
1323
|
+
? [` ${where} is a Claude session of your approval class, but your own Claude Code does not list it among the live sessions its messaging reaches: it runs under another Claude config folder, or it is not running. So the mailbox is the road that reaches it.`]
|
|
1324
|
+
: []),
|
|
1322
1325
|
` Send it: ${shellWord(ownCli())} message send --bots ${shellWord(bots)} --to ${shellWord(where)} --subject <text> --text <text>`,
|
|
1323
1326
|
];
|
|
1324
1327
|
}
|
|
@@ -1932,12 +1935,17 @@ function tabLines({ bots, created, completed, rules, skills, permissions = [], t
|
|
|
1932
1935
|
const back = one.session === undefined ? '' : ` --session ${one.session}`;
|
|
1933
1936
|
lines.push(`${'paused'.padEnd(9)} ${what} left closed. Bring it back: ${shellWord(ownCli())} unpause --bots ${shellWord(bots)} --bot ${one.bot}${back}`);
|
|
1934
1937
|
}
|
|
1938
|
+
// A window that has not loaded a bot's project gives its tabs no notice of
|
|
1939
|
+
// mail (#676).
|
|
1940
|
+
for (const one of projects.filter((project) => project.windowTrouble !== undefined)) {
|
|
1941
|
+
lines.push(`${'window'.padEnd(9)} ${displayName(one.bot)} its tabs may get no fleet mail until Orca's window loads its project: ${one.windowTrouble}. Try again: ${shellWord(ownCli())} up --bots ${shellWord(bots)} --bot ${one.bot}`);
|
|
1942
|
+
}
|
|
1935
1943
|
|
|
1936
1944
|
// Last before the summary, because what a check found is about the setup the
|
|
1937
1945
|
// run has just left behind rather than about any one thing it did.
|
|
1938
1946
|
lines.push(...foundLines(found));
|
|
1939
1947
|
// Hedged, because nothing here can see whether the window was told (#224).
|
|
1940
|
-
if (projects.
|
|
1948
|
+
if (projects.some((project) => project.change !== undefined)) lines.push(RELOAD_LINE);
|
|
1941
1949
|
lines.push(summary);
|
|
1942
1950
|
return lines;
|
|
1943
1951
|
}
|
package/src/hooks.js
CHANGED
|
@@ -101,7 +101,8 @@ export function installHook(home, harness, { bots, bot }) {
|
|
|
101
101
|
const started = withKitHook(settings.hooks, file, mine);
|
|
102
102
|
// The Claude Code mail hook and the Codex nudge hook went with the kit's
|
|
103
103
|
// typed line (#555), so an entry of either is taken out.
|
|
104
|
-
const
|
|
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
|
*
|
|
@@ -435,7 +435,7 @@ export function launchCommand(session, { harness, home, workDir, prompt, promptF
|
|
|
435
435
|
|
|
436
436
|
if (harness === 'claude') {
|
|
437
437
|
// The name is the address other Claude sessions write to, so it goes on
|
|
438
|
-
// every launch line, a resume included (ADR
|
|
438
|
+
// every launch line, a resume included (ADR 0053).
|
|
439
439
|
if (address !== undefined) words.push('-n', address);
|
|
440
440
|
// The context window rides on the model name: `sonnet[1m]`.
|
|
441
441
|
if (set(session.model)) words.push('--model', set(session.context) ? `${session.model}[${session.context}]` : session.model);
|
package/src/message.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// `obk message`: one session writing to another (PRD 6.9, ADR
|
|
1
|
+
// `obk message`: one session writing to another (PRD 6.9, ADR 0053).
|
|
2
2
|
//
|
|
3
3
|
// Two roads, and the bot never picks. Claude to Claude in the same approval
|
|
4
4
|
// class is the harness's own messaging, which no command line can send for it —
|
|
@@ -10,19 +10,21 @@
|
|
|
10
10
|
// refuses a send once the pane is gone. A Run survives the tab, the relaunch
|
|
11
11
|
// and the restart, so a session's address is its Run (tech notes, section 1).
|
|
12
12
|
//
|
|
13
|
-
// One road and one signal (#555, ADR
|
|
13
|
+
// One road and one signal (#555, ADR 0053): the letter goes into the
|
|
14
14
|
// receiver's mailbox, and Orca's own notice tells the receiver to look once it
|
|
15
15
|
// is idle. The kit types nothing into the receiver's tab, except the one
|
|
16
16
|
// Escape a sender asks for with --interrupt. Orca gives no notice to a tab its
|
|
17
17
|
// window has not loaded, so the send says when the receiver's is one (#579).
|
|
18
18
|
|
|
19
|
-
import { mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { mkdirSync, readdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { homedir } from 'node:os';
|
|
20
21
|
import path from 'node:path';
|
|
22
|
+
import { setTimeout as pause } from 'node:timers/promises';
|
|
21
23
|
|
|
22
24
|
import { MAILBOX_WAIT_MS, readBook, takeLineTurn, takeMailboxTurn, TYPING_HELD, TYPING_WAIT_MS } from './book.js';
|
|
23
25
|
import { botDir, botNames, readBot } from './bot.js';
|
|
24
26
|
import { harnessOf, isAddressOf, kitFolders, ownCli, reachesMail, SHELL_ENV, shellWord } from './launch.js';
|
|
25
|
-
import { ackMailbox, coordinatorOf, postMessage, pressEscape, readMailbox, tabs, tabToTypeInto, TERMINAL_ENV, TIMED_OUT, useMailbox } from './orca.js';
|
|
27
|
+
import { ackMailbox, coordinatorOf, isRunning, listedOrphaned, loadInWindow, postMessage, pressEscape, readMailbox, tabs, tabToTypeInto, TERMINAL_ENV, TIMED_OUT, useMailbox } from './orca.js';
|
|
26
28
|
|
|
27
29
|
/**
|
|
28
30
|
* How much of a message travels as itself. Above this it is written to a file
|
|
@@ -88,11 +90,16 @@ export function findSession(bots, target) {
|
|
|
88
90
|
*/
|
|
89
91
|
export function roadBetween(from, to) {
|
|
90
92
|
const pair = from.harness === 'claude' && to.harness === 'claude' && from.approval === to.approval;
|
|
91
|
-
|
|
93
|
+
const mailbox = to.mailbox === undefined ? undefined : `run:${to.mailbox}`;
|
|
94
|
+
if (pair && to.address !== undefined) {
|
|
95
|
+
// Native only to a session the sender's own Claude Code lists: its
|
|
96
|
+
// messaging reaches no other, and the mailbox does (#700).
|
|
97
|
+
return listedLive(to.address) ? { transport: 'native', address: to.address } : { transport: 'orca', address: mailbox, unlisted: true };
|
|
98
|
+
}
|
|
92
99
|
|
|
93
100
|
return {
|
|
94
101
|
transport: 'orca',
|
|
95
|
-
address:
|
|
102
|
+
address: mailbox,
|
|
96
103
|
// A Claude pair that the native road cannot carry, because the receiver is
|
|
97
104
|
// running under no name the kit gave it: it was started before the kit
|
|
98
105
|
// named sessions, or before it gave them names of their own (#286), and
|
|
@@ -103,6 +110,38 @@ export function roadBetween(from, to) {
|
|
|
103
110
|
};
|
|
104
111
|
}
|
|
105
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Whether the sender's own Claude Code lists `address` as a live session. Its
|
|
115
|
+
* messaging reaches only the sessions in its own registry, one
|
|
116
|
+
* `<pid>.json` for each, in `sessions` under its config dir, and two Claude
|
|
117
|
+
* Codes with two config dirs do not see each other: Orca starts a tab under a
|
|
118
|
+
* Claude profile of its own after an account switch, and a session started
|
|
119
|
+
* before it is in `~/.claude` (#700; tech notes, section 2). The command runs
|
|
120
|
+
* in the sender's environment, so its CLAUDE_CONFIG_DIR is the sender's. An
|
|
121
|
+
* entry counts when its `name` is the address, unless `ps` says its pid is not
|
|
122
|
+
* there: a check that cannot tell keeps the entry, so a slow `ps` opens no
|
|
123
|
+
* second road to a session the sender can reach (review of PR #702). A folder
|
|
124
|
+
* or a file that cannot be read lists nobody.
|
|
125
|
+
*/
|
|
126
|
+
function listedLive(address) {
|
|
127
|
+
const registry = path.join(process.env.CLAUDE_CONFIG_DIR || path.join(homedir(), '.claude'), 'sessions');
|
|
128
|
+
let files;
|
|
129
|
+
try {
|
|
130
|
+
files = readdirSync(registry).filter((file) => file.endsWith('.json'));
|
|
131
|
+
} catch {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
return files.some((file) => {
|
|
135
|
+
let entry;
|
|
136
|
+
try {
|
|
137
|
+
entry = JSON.parse(readFileSync(path.join(registry, file), 'utf8'));
|
|
138
|
+
} catch {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
return entry?.name === address && Number.isInteger(entry.pid) && entry.pid > 0 && isRunning(entry.pid, LOOK_MS) !== false;
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
106
145
|
/** What `obk message to` answers: the road, the address, and who is at each end. */
|
|
107
146
|
export function lookUp(bots, { to: target, from: sender, tab }) {
|
|
108
147
|
const from = whoIsWriting(bots, sender, tab);
|
|
@@ -161,7 +200,7 @@ export async function sendMessage(bots, { to: target, from: sender, tab, subject
|
|
|
161
200
|
|
|
162
201
|
const written = bodyOf(bots, { from, to, subject, text, textFile });
|
|
163
202
|
// Looked at before the post, which is the send's last call to Orca (PRD 6.9).
|
|
164
|
-
const shown = loaded(to);
|
|
203
|
+
const shown = await loaded(to);
|
|
165
204
|
// The Escape goes first, so the turn has ended when the letter is in.
|
|
166
205
|
const interrupted = interrupt ? interruptBusy(to) : {};
|
|
167
206
|
const message = postMessage({
|
|
@@ -189,20 +228,50 @@ export async function sendMessage(bots, { to: target, from: sender, tab, subject
|
|
|
189
228
|
* and lists one it has not as orphaned, as after a window restart (#579; tech
|
|
190
229
|
* notes, section 1). So an orphaned tab is said, with its handle, and a
|
|
191
230
|
* listing that cannot be read is said too, rather than taken for a loaded tab.
|
|
192
|
-
*
|
|
231
|
+
* An orphaned tab of a project the window has never fetched is loaded by
|
|
232
|
+
* `loadInWindow`, which changes nothing on screen, so the kit asks for that
|
|
233
|
+
* and looks again until LOOK_MS after the ask (#676). A tab it does not load,
|
|
234
|
+
* as after a window restart (#579), is said as before. This is all before the
|
|
235
|
+
* send: a tab orphaned after it is not seen here.
|
|
193
236
|
*/
|
|
194
|
-
function loaded(to) {
|
|
237
|
+
async function loaded(to) {
|
|
195
238
|
if (to.tab === undefined) return {};
|
|
196
|
-
let
|
|
239
|
+
let tab;
|
|
197
240
|
try {
|
|
198
|
-
|
|
241
|
+
tab = tabs(to.home, { timeoutMs: LOOK_MS }).find((one) => one.tabId === to.tab);
|
|
199
242
|
} catch (error) {
|
|
200
243
|
return { loadTrouble: error.message };
|
|
201
244
|
}
|
|
202
|
-
|
|
203
|
-
return
|
|
245
|
+
if (tab?.orphaned !== true) return {};
|
|
246
|
+
return (await loadedNow(to.home, tab.handle)) ? {} : { orphaned: true, handle: tab.handle };
|
|
204
247
|
}
|
|
205
248
|
|
|
249
|
+
/**
|
|
250
|
+
* Whether the window loads the orphaned tab `handle` at `home` once asked to.
|
|
251
|
+
* The ask and every look after it keep to one deadline, LOOK_MS from the ask,
|
|
252
|
+
* and each Orca call gets only the time that is left, so a slow Orca cannot
|
|
253
|
+
* hold the send up for longer (#676). A look that fails or runs out counts as
|
|
254
|
+
* a tab still orphaned.
|
|
255
|
+
*/
|
|
256
|
+
async function loadedNow(home, handle) {
|
|
257
|
+
const until = Date.now() + LOOK_MS;
|
|
258
|
+
const left = () => until - Date.now();
|
|
259
|
+
loadInWindow(home, { timeoutMs: LOOK_MS });
|
|
260
|
+
try {
|
|
261
|
+
for (let ms = left(); ms > 0; ms = left()) {
|
|
262
|
+
if (!listedOrphaned(home, handle, { timeoutMs: ms })) return true;
|
|
263
|
+
const wait = Math.min(RELOOK_MS, left());
|
|
264
|
+
if (wait > 0) await pause(wait);
|
|
265
|
+
}
|
|
266
|
+
} catch {
|
|
267
|
+
// A look that fails or runs out leaves the tab said as orphaned.
|
|
268
|
+
}
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** How long the kit waits between two looks at a tab it asked the window to load: it was loaded within 0.4 s, live (#676). */
|
|
273
|
+
const RELOOK_MS = 200;
|
|
274
|
+
|
|
206
275
|
/**
|
|
207
276
|
* What is waiting for a session, and the reading of it.
|
|
208
277
|
*
|
package/src/name.js
CHANGED
|
@@ -19,6 +19,7 @@ import { readBook, takeNameTurn, takeTypingTurn } from './book.js';
|
|
|
19
19
|
import { botDir, readBot } from './bot.js';
|
|
20
20
|
import { sessionToType, typeCommand } from './clear.js';
|
|
21
21
|
import { harnessOf } from './launch.js';
|
|
22
|
+
import { mailIn } from './orca.js';
|
|
22
23
|
import { sessionsOf } from './up.js';
|
|
23
24
|
|
|
24
25
|
/**
|
|
@@ -40,6 +41,9 @@ const DEADLINE_MS = 240_000;
|
|
|
40
41
|
const CONFIRM_MS = 10_000;
|
|
41
42
|
const ASK_MS = 500;
|
|
42
43
|
|
|
44
|
+
/** How many of a mailbox's newest letters are read, as `obk health` reads them. */
|
|
45
|
+
const MAIL_READ = 100;
|
|
46
|
+
|
|
43
47
|
/**
|
|
44
48
|
* Name the thread that just ended a turn in the tab `tabId`, as Codex's Stop
|
|
45
49
|
* hook `said` it, when that tab is a Codex session of `bot` and its thread does
|
|
@@ -85,8 +89,27 @@ export async function nameSession(bots, bot, said, tabId) {
|
|
|
85
89
|
typing = takeTypingTurn(home, session, 0);
|
|
86
90
|
if (typing === undefined) throw new Error(`${it.name}: nothing was typed, because the kit is typing into its tab already.`);
|
|
87
91
|
};
|
|
92
|
+
// Orca types its notice into an idle tab, the input line or not, and a
|
|
93
|
+
// notice inside the command became the thread's name (#713). A letter not
|
|
94
|
+
// delivered yet is one Orca is about to ring for: name later. Read last
|
|
95
|
+
// of all before the write.
|
|
96
|
+
let footer;
|
|
97
|
+
const last = (ready) => {
|
|
98
|
+
footer = rowUnder(ready.rows);
|
|
99
|
+
const mailbox = entry.mailbox;
|
|
100
|
+
if (mailbox === undefined) return;
|
|
101
|
+
let letters;
|
|
102
|
+
try {
|
|
103
|
+
letters = mailIn(mailbox, MAIL_READ);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
throw new Error(`${it.name}: nothing was typed, because its mailbox could not be read: ${error.message}`);
|
|
106
|
+
}
|
|
107
|
+
if (letters.some((letter) => !letter.read && letter.delivered_at == null)) {
|
|
108
|
+
throw new Error(`${it.name}: nothing was typed, because Orca has yet to tell it of mail.`);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
88
111
|
try {
|
|
89
|
-
await typeCommand(it, `/rename ${name}`, { check: renameWrong, deadline, before });
|
|
112
|
+
await typeCommand(it, `/rename ${name}`, { check: renameWrong(() => footer), deadline, before, last, whole: true });
|
|
90
113
|
} finally {
|
|
91
114
|
typing?.release();
|
|
92
115
|
}
|
|
@@ -119,6 +142,12 @@ function nameOf(thread) {
|
|
|
119
142
|
return name;
|
|
120
143
|
}
|
|
121
144
|
|
|
145
|
+
/** The first row with text under the input line, the lowest row Codex's pointer starts, or undefined. */
|
|
146
|
+
function rowUnder(rows) {
|
|
147
|
+
const at = rows.findLastIndex((row) => /^ *›/.test(row));
|
|
148
|
+
return rows.slice(at + 1).find((row) => row.trim() !== '')?.trimEnd();
|
|
149
|
+
}
|
|
150
|
+
|
|
122
151
|
/**
|
|
123
152
|
* Why the screen does not show `command` typed and ready, or undefined when it
|
|
124
153
|
* does: the input line, the lowest row Codex's pointer starts, reads the
|
|
@@ -127,13 +156,18 @@ function nameOf(thread) {
|
|
|
127
156
|
* `sync_command_popup`, rust-v0.160.0), so a menu still open means the line is
|
|
128
157
|
* not what it seems. Where Orca gives a draft, the draft is the line's text:
|
|
129
158
|
* Codex 0.162.0's line on the screen then reads its pointer alone (#516).
|
|
159
|
+
* Under the line, the first row with text is the one that stood there before
|
|
160
|
+
* the write, `footer()`: Codex's status row, whatever the user set it to show.
|
|
130
161
|
*/
|
|
131
|
-
|
|
162
|
+
const renameWrong = (footer) => (rows, _harness, command, _version, draft) => {
|
|
132
163
|
const at = rows.findLastIndex((row) => /^ *›/.test(row));
|
|
133
164
|
if (at < 0) return { why: 'its screen shows no input line' };
|
|
134
165
|
const line = draft === undefined ? rows[at].replaceAll(' ', ' ').trim() : `› ${draft}`;
|
|
135
166
|
if (line !== `› ${command}`) return { why: `its input line reads "${line}"` };
|
|
136
167
|
const above = rows.slice(0, at).findLast((row) => row.trim() !== '');
|
|
137
168
|
if (above !== undefined && /^ *(?:› +)?\/\S/.test(above)) return { why: `a menu of commands stands above its input line: "${above.trim()}"` };
|
|
169
|
+
// Any other row there is more of the line: Orca's notice, for one (#713).
|
|
170
|
+
const under = rowUnder(rows);
|
|
171
|
+
if (under !== footer()) return { why: `text stands under its input line: "${under?.trim()}"` };
|
|
138
172
|
return undefined;
|
|
139
|
-
}
|
|
173
|
+
};
|
package/src/orca.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// handed to Orca on the command line.
|
|
9
9
|
|
|
10
10
|
import { spawnSync } from 'node:child_process';
|
|
11
|
-
import { existsSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
12
12
|
import { homedir, tmpdir } from 'node:os';
|
|
13
13
|
import path from 'node:path';
|
|
14
14
|
import { DatabaseSync } from 'node:sqlite';
|
|
@@ -290,12 +290,63 @@ function argsIn(read) {
|
|
|
290
290
|
* repo as git kind, which Orca gives no workspace at all, so no tab can be
|
|
291
291
|
* created in it. `setup-update` turns it into the folder workspace a bot needs
|
|
292
292
|
* and names the project in one go.
|
|
293
|
+
*
|
|
294
|
+
* Orca 1.4.236 and later register the top folder of the git repo that holds
|
|
295
|
+
* the folder they are given: for a bot home, the whole bots folder (#712). So
|
|
296
|
+
* for the time of `repo add` the home gets a `.git` of its own, which makes it
|
|
297
|
+
* its own top folder, and that `.git` goes straight after. One that was there
|
|
298
|
+
* before is the user's, and stays as it was.
|
|
293
299
|
*/
|
|
294
300
|
export function makeProject(home, title) {
|
|
295
|
-
const
|
|
301
|
+
const made = gitOfItsOwn(home);
|
|
302
|
+
let setupId;
|
|
303
|
+
try {
|
|
304
|
+
setupId = orca(['repo', 'add', '--path', home]).repo.id;
|
|
305
|
+
} finally {
|
|
306
|
+
if (made) rmSync(path.join(home, '.git'), { recursive: true, force: true });
|
|
307
|
+
}
|
|
296
308
|
return asFolderProject(setupId, title);
|
|
297
309
|
}
|
|
298
310
|
|
|
311
|
+
/** The file inside a `.git` that says the kit made it for one `repo add`. */
|
|
312
|
+
const KIT_GIT_MARK = 'obk-made-for-orca';
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Give `home` a `.git` of its own: true when this call made it, false when one
|
|
316
|
+
* is there already. The mkdir fails on anything already there, so the kit
|
|
317
|
+
* never takes over a `.git` it did not make, and the mark goes in before git
|
|
318
|
+
* fills it, so one left by a run that died is known as the kit's.
|
|
319
|
+
*/
|
|
320
|
+
function gitOfItsOwn(home) {
|
|
321
|
+
const git = path.join(home, '.git');
|
|
322
|
+
try {
|
|
323
|
+
mkdirSync(git);
|
|
324
|
+
} catch (error) {
|
|
325
|
+
if (error.code === 'EEXIST') return false;
|
|
326
|
+
throw error;
|
|
327
|
+
}
|
|
328
|
+
try {
|
|
329
|
+
writeFileSync(path.join(git, KIT_GIT_MARK), 'obk made this for one `orca repo add` and takes it away after.\n');
|
|
330
|
+
const result = spawnSync('git', ['init', '--quiet', home], { encoding: 'utf8' });
|
|
331
|
+
if (result.error) throw new Error(`could not run git: ${result.error.message}`);
|
|
332
|
+
if (result.status !== 0) throw new Error(`git init failed in ${home}: ${(result.stderr || '').trim()}`);
|
|
333
|
+
} catch (error) {
|
|
334
|
+
rmSync(git, { recursive: true, force: true });
|
|
335
|
+
throw error;
|
|
336
|
+
}
|
|
337
|
+
return true;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Take away the `.git` that a run which died during `makeProject` left in
|
|
342
|
+
* `home`. Only one that holds the kit's mark: any other is the user's.
|
|
343
|
+
*/
|
|
344
|
+
export function dropLeftGit(home) {
|
|
345
|
+
const git = path.join(home, '.git');
|
|
346
|
+
const stat = lstatSync(git, { throwIfNoEntry: false });
|
|
347
|
+
if (stat?.isDirectory() && existsSync(path.join(git, KIT_GIT_MARK))) rmSync(git, { recursive: true, force: true });
|
|
348
|
+
}
|
|
349
|
+
|
|
299
350
|
/** Make an existing registration a folder workspace called `title`. */
|
|
300
351
|
export function asFolderProject(setupId, title) {
|
|
301
352
|
return orca(['project', 'setup-update', '--setup', setupId, '--kind', 'folder', '--display-name', title])
|
|
@@ -347,6 +398,29 @@ const CLIENT_KILL_MS = 3000;
|
|
|
347
398
|
*/
|
|
348
399
|
export const tellWindow = (projectId) => askRuntime('project.update', { projectId, updates: {} }) !== undefined;
|
|
349
400
|
|
|
401
|
+
/**
|
|
402
|
+
* Have Orca's window load the tabs of the Orca project at `home`: undefined
|
|
403
|
+
* when Orca took the call, or what went wrong.
|
|
404
|
+
*
|
|
405
|
+
* The window loads a project's tabs only once it has fetched the project's
|
|
406
|
+
* worktrees, and nothing fetches them for a project the kit made, so its tabs
|
|
407
|
+
* stay orphaned and get no notice of mail (#676, tech notes). `worktree set`
|
|
408
|
+
* with no field writes an empty change and has Orca send the window the event
|
|
409
|
+
* that fetches them: nothing the user sees changes. What went wrong is said
|
|
410
|
+
* without Orca's own words, which name worktrees (PRD KIT-22: the kit says
|
|
411
|
+
* "Orca project"). `options` are `orca`'s.
|
|
412
|
+
*/
|
|
413
|
+
export function loadInWindow(home, options) {
|
|
414
|
+
try {
|
|
415
|
+
orca(['worktree', 'set', '--worktree', `path:${home}`], options);
|
|
416
|
+
return undefined;
|
|
417
|
+
} catch (error) {
|
|
418
|
+
return typeof error.code === 'string' && !/worktree/i.test(error.code)
|
|
419
|
+
? `Orca refused the call (${error.code})`
|
|
420
|
+
: 'Orca gave no answer the kit could read';
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
350
424
|
/**
|
|
351
425
|
* One call to Orca's runtime for a method its CLI does not offer, and the
|
|
352
426
|
* runtime's answer, or undefined when there is none to be had.
|
|
@@ -433,6 +507,14 @@ export const tabs = (home, options) => orca(['terminal', 'list', '--worktree', `
|
|
|
433
507
|
? { ...tab, tabId: orca(['terminal', 'show', '--terminal', tab.handle], options).terminal.tabId }
|
|
434
508
|
: tab));
|
|
435
509
|
|
|
510
|
+
/**
|
|
511
|
+
* Whether Orca lists the terminal `handle` at `home` as orphaned, from one
|
|
512
|
+
* listing and no `terminal show`, so a caller with a deadline can give the one
|
|
513
|
+
* call all the time it has left (#676).
|
|
514
|
+
*/
|
|
515
|
+
export const listedOrphaned = (home, handle, options) => orca(['terminal', 'list', '--worktree', `path:${home}`], options).terminals
|
|
516
|
+
.some((tab) => tab.handle === handle && tab.orphaned === true);
|
|
517
|
+
|
|
436
518
|
/**
|
|
437
519
|
* Open a tab in the Orca project at `home`, titled `title`.
|
|
438
520
|
*
|
|
@@ -1017,9 +1099,25 @@ export function wordsOfProcess(pid) {
|
|
|
1017
1099
|
return asked.stdout.trim().split(/\s+/);
|
|
1018
1100
|
}
|
|
1019
1101
|
|
|
1102
|
+
/**
|
|
1103
|
+
* Whether the process `pid` runs now, as `ps` answers for it: false only when
|
|
1104
|
+
* `ps` says no process has that pid, and undefined when it cannot tell: `ps`
|
|
1105
|
+
* did not start, failed, ran out of time or printed a line it cannot read.
|
|
1106
|
+
* macOS's `ps` answers a pid that is not there with exit 1 and nothing on
|
|
1107
|
+
* stdout or stderr (seen on macOS 27.0.1), and it exits 1 on an error of its
|
|
1108
|
+
* own too, with the error on stderr (review of PR #702).
|
|
1109
|
+
*/
|
|
1110
|
+
export function isRunning(pid, readMs) {
|
|
1111
|
+
const asked = askPs(pid, readMs);
|
|
1112
|
+
if (asked.error === undefined && asked.status === 1 && asked.stdout.trim() === '' && asked.stderr.trim() === '') return false;
|
|
1113
|
+
return psLine(pid, readMs, asked)?.comm === undefined ? undefined : true;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
const askPs = (pid, readMs) =>
|
|
1117
|
+
spawnSync(PS, ['-o', 'pid=,ppid=,tpgid=,comm=', '-p', String(pid)], { encoding: 'utf8', ...(readMs === undefined ? {} : { timeout: readMs }) });
|
|
1118
|
+
|
|
1020
1119
|
/** One process as `ps` gives it, read only: `{ ppid, tpgid, comm }`, `{ late: true }` past `readMs`, or undefined. */
|
|
1021
|
-
function psLine(pid, readMs) {
|
|
1022
|
-
const asked = spawnSync(PS, ['-o', 'pid=,ppid=,tpgid=,comm=', '-p', String(pid)], { encoding: 'utf8', ...(readMs === undefined ? {} : { timeout: readMs }) });
|
|
1120
|
+
function psLine(pid, readMs, asked = askPs(pid, readMs)) {
|
|
1023
1121
|
if (asked.error?.code === 'ETIMEDOUT') return { late: true };
|
|
1024
1122
|
if (asked.error || asked.status !== 0) return undefined;
|
|
1025
1123
|
const line = /^\s*(\d+)\s+(\d+)\s+(-?\d+)\s+(.+?)\s*$/.exec(asked.stdout);
|
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,14 @@ import { existsSync, mkdirSync, realpathSync, writeFileSync } from 'node:fs';
|
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
import { setTimeout as pause } from 'node:timers/promises';
|
|
11
11
|
|
|
12
|
-
import { forgetClaimed, forgetSession, MAILBOX_WAIT_MS, readBook, rememberSession, sessionIdsIn, tabIdsIn, takeLineTurn, takeMailboxTurn, TYPING_HELD, TYPING_WAIT_MS, updateBook, withUnclaimed } from './book.js';
|
|
12
|
+
import { forgetClaimed, forgetSession, MAILBOX_WAIT_MS, readBook, rememberSession, sessionIdsIn, tabIdsIn, takeLineTurn, takeMailboxTurn, takeProjectTurn, TYPING_HELD, TYPING_WAIT_MS, updateBook, withUnclaimed } from './book.js';
|
|
13
13
|
import { botDir, botNames, displayName, readBot } from './bot.js';
|
|
14
14
|
import { conversationsIn, hasConversation, heldAsUserTurn, transcriptsIn } from './conversations.js';
|
|
15
15
|
import { dropRemovedHooks, installHook } from './hooks.js';
|
|
16
16
|
import { writePermissions } from './permissions.js';
|
|
17
17
|
import { piTrouble } from './pi.js';
|
|
18
|
-
import { addressOf, HARNESSES, harnessOf, isAddressOf, isShortPrompt, kitFolders, launchCommand, mailboxStep, reachesMail, sessionTrouble, startPrompt, workDirOf } from './launch.js';
|
|
19
|
-
import { asFolderProject, coordinatorOf, findProject, harnessInTab, makeMailbox, makeProject, openTab, QUESTION_ON_SCREEN, retitleTab, runMissing, screenRows, shellInTab, tabs, tabToTypeInto, TERMINAL_ENV, TIMED_OUT, tellWindow, typeIntoTab, useMailbox } from './orca.js';
|
|
18
|
+
import { addressOf, HARNESSES, harnessOf, isAddressOf, isShortPrompt, kitFolders, launchCommand, mailboxStep, ownCli, reachesMail, sessionTrouble, shellWord, startPrompt, workDirOf } from './launch.js';
|
|
19
|
+
import { asFolderProject, coordinatorOf, dropLeftGit, findProject, harnessInTab, loadInWindow, makeMailbox, makeProject, openTab, QUESTION_ON_SCREEN, retitleTab, runMissing, screenRows, shellInTab, tabs, tabToTypeInto, TERMINAL_ENV, TIMED_OUT, tellWindow, typeIntoTab, useMailbox } from './orca.js';
|
|
20
20
|
import { TAB_ENV } from './record.js';
|
|
21
21
|
import { buildAgents, rulesStamp } from './rules.js';
|
|
22
22
|
import { linkSkills } from './skills.js';
|
|
@@ -40,7 +40,9 @@ const SECOND_LOOK_MS = 2000;
|
|
|
40
40
|
* Bring bots up in Orca. Returns `{ tabs, rules }`: one tab entry per tab it
|
|
41
41
|
* looked at — the sessions the book knows, and, for Bot Father, whatever else
|
|
42
42
|
* is open in its project — and one rules entry and one skills entry per bot.
|
|
43
|
-
* `projects` has one entry per Orca project this run made or renamed
|
|
43
|
+
* `projects` has one entry per Orca project this run made or renamed, and per
|
|
44
|
+
* project Orca's window may not have loaded, with what went wrong as
|
|
45
|
+
* `windowTrouble` (#676).
|
|
44
46
|
*
|
|
45
47
|
* With no name it is every bot in the folder, in name order. `bot` brings up
|
|
46
48
|
* one bot and `session` one of its sessions, for a caller that wants one thing
|
|
@@ -149,12 +151,14 @@ export function prepareBots(bots, names, onlySession) {
|
|
|
149
151
|
// and a bot folder the kit cannot write it into stops the run with nothing
|
|
150
152
|
// opened anywhere. Only the harnesses a bot actually runs on; a bot with no
|
|
151
153
|
// sessions gets none. The hooks of commands the kit no longer has go from
|
|
152
|
-
// every hooks file the bot has, of a harness it has left too (#613).
|
|
154
|
+
// every hooks file the bot has, of a harness it has left too (#613). That
|
|
155
|
+
// comes after the kit's own hooks, which take them out of the files they
|
|
156
|
+
// write anyway, so a file the kit refuses is left as the user wrote it (#708).
|
|
153
157
|
for (const { bot, home } of running) {
|
|
154
|
-
dropRemovedHooks(home);
|
|
155
158
|
for (const harness of new Set(awake(sessionsOf(bot, onlySession)).map((session) => harnessOf(session, bot.harness)))) {
|
|
156
159
|
installHook(home, harness, { bots, bot: bot.name });
|
|
157
160
|
}
|
|
161
|
+
dropRemovedHooks(home);
|
|
158
162
|
}
|
|
159
163
|
|
|
160
164
|
// And the permission rules the user allowed, beside the hook and for the same
|
|
@@ -238,6 +242,10 @@ async function bringUpBot(bots, home, bot, onlySession) {
|
|
|
238
242
|
// Orca's window does not see a project made or renamed on the command line
|
|
239
243
|
// until it is told (#224).
|
|
240
244
|
if (change !== undefined) tellWindow(orca.project);
|
|
245
|
+
// Its tabs get no notice of mail until the window loads them, and the window
|
|
246
|
+
// does not load a project it has never fetched (#676). Every run asks, so a
|
|
247
|
+
// project made before this is loaded too; the call changes nothing on screen.
|
|
248
|
+
const windowTrouble = loadInWindow(home);
|
|
241
249
|
|
|
242
250
|
const live = new Map(tabs(home).map((tab) => [tab.tabId, tab]));
|
|
243
251
|
const report = [];
|
|
@@ -256,7 +264,13 @@ async function bringUpBot(bots, home, bot, onlySession) {
|
|
|
256
264
|
: spare.map((tab) => entry(tab, { bot: name, name: null, created: false }))));
|
|
257
265
|
}
|
|
258
266
|
|
|
259
|
-
|
|
267
|
+
const project = {
|
|
268
|
+
bot: name,
|
|
269
|
+
project: orca.project,
|
|
270
|
+
...(change === undefined ? {} : { change }),
|
|
271
|
+
...(windowTrouble === undefined ? {} : { windowTrouble }),
|
|
272
|
+
};
|
|
273
|
+
return { tabs: report, project: change === undefined && windowTrouble === undefined ? undefined : project };
|
|
260
274
|
}
|
|
261
275
|
|
|
262
276
|
async function bringUpSession(bots, home, live, session, bot, title) {
|
|
@@ -421,7 +435,7 @@ async function bringUpSession(bots, home, live, session, bot, title) {
|
|
|
421
435
|
// 0.157.1 (#226). So it is given one line, through the same gate as a mail
|
|
422
436
|
// notice, which the owner allowed. A fresh start's first turn is its prompt.
|
|
423
437
|
const list = harness === 'codex' && launch.resume !== undefined && tui.running === true
|
|
424
|
-
? await typeListLine(home, session.name, made.tabId)
|
|
438
|
+
? await typeListLine(home, session.name, made.tabId, { line: listLine(bots) })
|
|
425
439
|
: undefined;
|
|
426
440
|
|
|
427
441
|
return entry(made, {
|
|
@@ -491,8 +505,15 @@ async function shellNotReady(handle) {
|
|
|
491
505
|
: `the shell is asking: ${last.trim()} (${wait})`;
|
|
492
506
|
}
|
|
493
507
|
|
|
494
|
-
/**
|
|
495
|
-
|
|
508
|
+
/**
|
|
509
|
+
* The line a resumed Codex session is typed so Orca lists it, in the
|
|
510
|
+
* architect's words (#226). It names the kit's mail check as the bot's rules
|
|
511
|
+
* do, because a restart often comes for mail that is waiting (#630), and a line
|
|
512
|
+
* that asked for "ok" alone had the session drop the mail it had just read (#709).
|
|
513
|
+
*/
|
|
514
|
+
const listLine = (bots) => 'obk: this session was resumed in a new tab, and the kit types this line so Orca lists it.'
|
|
515
|
+
+ ` Read your mail with \`${shellWord(ownCli())} message check --bots ${shellWord(bots)}\`, and do what it asks.`
|
|
516
|
+
+ ' That includes mail you read just before this line. If there is none, reply "ok".';
|
|
496
517
|
|
|
497
518
|
/**
|
|
498
519
|
* How long a resumed Codex tab is given to be one the kit may type into: live,
|
|
@@ -504,15 +525,15 @@ const LIST_LOOK_MS = 2000;
|
|
|
504
525
|
const LIST_ASK_MS = 500;
|
|
505
526
|
|
|
506
527
|
/**
|
|
507
|
-
* Type `line
|
|
508
|
-
*
|
|
528
|
+
* Type `line` into the tab `tabId` of `session` once the gate every typed
|
|
529
|
+
* line goes through lets it:
|
|
509
530
|
* `{ typed: true }`, or `{ typed: false, why }` with the gate's last reason
|
|
510
531
|
* once LIST_WAIT_MS is up. Nothing goes into a question, a form or a menu
|
|
511
532
|
* (#329, #416), nor into a tab the kit cannot tell about. Each look and its
|
|
512
533
|
* line are taken in the session's turn for a line (#482), unless the caller
|
|
513
534
|
* says it holds the session's typing turn already (`held`).
|
|
514
535
|
*/
|
|
515
|
-
export async function typeListLine(home, session, tabId, { line
|
|
536
|
+
export async function typeListLine(home, session, tabId, { line, held = false }) {
|
|
516
537
|
const until = Date.now() + LIST_WAIT_MS;
|
|
517
538
|
for (;;) {
|
|
518
539
|
let turn;
|
|
@@ -914,9 +935,15 @@ const setAside = (entry, which) => (which.ranOn === undefined
|
|
|
914
935
|
* all, so no tab can be opened in it.
|
|
915
936
|
*/
|
|
916
937
|
function orcaProject(home, title) {
|
|
917
|
-
const
|
|
918
|
-
|
|
919
|
-
|
|
938
|
+
const turn = takeProjectTurn(home);
|
|
939
|
+
try {
|
|
940
|
+
dropLeftGit(home);
|
|
941
|
+
const found = findProject(home);
|
|
942
|
+
if (found === undefined) return ids(makeProject(home, title), 'made');
|
|
943
|
+
return found.kind === 'folder' ? ids(found) : ids(asFolderProject(found.id, title), 'renamed');
|
|
944
|
+
} finally {
|
|
945
|
+
turn.release();
|
|
946
|
+
}
|
|
920
947
|
}
|
|
921
948
|
|
|
922
949
|
const ids = (setup, change) => ({ project: setup.projectId, setup: setup.id, change });
|