@assuredloop/orca-bot-kit 0.5.1 → 0.7.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 +38 -17
- package/SETUP.md +3 -2
- package/package.json +1 -1
- package/rules/commit.md +18 -0
- package/skills/obk-bot-building/SKILL.md +6 -2
- package/skills/obk-fleet-review/SKILL.md +2 -1
- package/skills/obk-grooming/SKILL.md +2 -1
- package/src/bot.js +26 -1
- package/src/cli.js +22 -3
- package/src/health.js +32 -17
- package/src/hooks.js +3 -3
- package/src/launch.js +27 -1
- package/src/message.js +44 -7
- package/src/orca.js +44 -19
- package/src/record.js +25 -11
- package/src/restart.js +10 -6
- package/src/skills.js +1 -1
- package/src/up.js +162 -57
package/README.md
CHANGED
|
@@ -340,7 +340,7 @@ with why that one ended and when
|
|
|
340
340
|
|
|
341
341
|
The book stays true to the harness through a hook `obk up` puts in the bot's own
|
|
342
342
|
folder — `.claude/settings.json` or `.codex/hooks.json`, never your user-level
|
|
343
|
-
settings ([ADR
|
|
343
|
+
settings ([ADR 0022](docs/adr/0022-kit-hooks-live-in-the-bot-folder.md)). Your own
|
|
344
344
|
settings in those files are kept. Codex asks you to trust a hooks file the first
|
|
345
345
|
time it sees one; answer its question in the tab.
|
|
346
346
|
|
|
@@ -391,7 +391,10 @@ What it will not do, in order of how much it would cost you. It closes only a
|
|
|
391
391
|
tab your book names, so Bot Father's ops tab and anything you opened yourself
|
|
392
392
|
are left alone. It will not close a tab whose conversation the book cannot name:
|
|
393
393
|
that close would be the end of that conversation, so it refuses, tells you which
|
|
394
|
-
session and which file to settle it in, and touches nothing.
|
|
394
|
+
session and which file to settle it in, and touches nothing. The one exception
|
|
395
|
+
is a tab with only its shell in front: its harness has already quit, so there
|
|
396
|
+
is nothing in it to lose, and restart closes it and starts the session fresh
|
|
397
|
+
with its duty. It will not close
|
|
395
398
|
anything at all until everything that would stop the session starting again has
|
|
396
399
|
been settled — the same refusals `obk up` gives, made before the tab goes rather
|
|
397
400
|
than after. If Orca refuses to close a tab it stops there, rather than start a
|
|
@@ -415,9 +418,11 @@ obk retire --bots /path/to/my-bots --bot api-bot [--session daily]
|
|
|
415
418
|
A change to a charter or a setting is written at once, and a running session
|
|
416
419
|
takes it when it next starts. A session keeps its harness: to move one, retire
|
|
417
420
|
it and add another. A pause closes the tabs the way a restart does, with the
|
|
418
|
-
same refusals, and
|
|
419
|
-
|
|
420
|
-
|
|
421
|
+
same refusals, and one more: it also refuses a tab with only its shell in front
|
|
422
|
+
whose conversation the book does not name. `obk up` leaves what is paused
|
|
423
|
+
closed until `obk unpause` brings it back with its conversations. Retiring a
|
|
424
|
+
session takes it off the bot and keeps its conversations in the book under
|
|
425
|
+
`retired`. Retiring a bot closes
|
|
421
426
|
its tabs, removes its Orca project and moves its folder to `retired/`; it will
|
|
422
427
|
not touch a bot whose Orca project holds a tab your book does not name. The
|
|
423
428
|
folder moves only once Orca no longer lists the project; if Orca still lists it,
|
|
@@ -438,11 +443,12 @@ link, not a tab — so everything it finds is yours to decide about.
|
|
|
438
443
|
|
|
439
444
|
What it looks for:
|
|
440
445
|
|
|
441
|
-
- **configuration that will not work**: a `bot.yaml` nothing can read, a
|
|
442
|
-
the kit
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
+
- **configuration that will not work**: a `bot.yaml` nothing can read, a key
|
|
447
|
+
in it the kit does not know (a typo such as `efort: high`, which nothing
|
|
448
|
+
reads), a session the kit would refuse to start, a bot with no `AGENTS.md`,
|
|
449
|
+
a block somebody edited by hand, a file bigger than the 32 KiB Codex reads, a
|
|
450
|
+
`CLAUDE.md` that is not this bot's rules, and a hooks file without the kit's
|
|
451
|
+
own hook in it, which is how a book goes stale without anything saying so;
|
|
446
452
|
- **a skill that is not where its list says**: a listed skill missing from one
|
|
447
453
|
of the two harnesses, a skill of your own standing where a listed one would
|
|
448
454
|
go, and a link with nothing at the end of it;
|
|
@@ -489,16 +495,28 @@ obk message check --bots /path/to/my-bots --bot api-bot --session daily
|
|
|
489
495
|
Two roads, and a bot never picks. Claude Code to Claude Code in the same
|
|
490
496
|
approval class (`auto` and `ask` are one class, `dangerously-skip` the other)
|
|
491
497
|
is the harness's own messaging: `message to` answers with the
|
|
492
|
-
session's name
|
|
493
|
-
and the sending session writes to that name itself, because no command
|
|
494
|
-
that message for it.
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
+
session's name, `<bot>.<session>.<token>`, which `obk up` puts on its launch
|
|
499
|
+
line, and the sending session writes to that name itself, because no command
|
|
500
|
+
can send that message for it. The token is new for each new conversation and
|
|
501
|
+
kept when one is resumed, because Claude Code refuses a name that other fleets'
|
|
502
|
+
sessions also answer to. Ask `message to` each time rather than keeping the
|
|
503
|
+
name. Everything else goes through Orca's mailbox, which the kit does carry.
|
|
504
|
+
|
|
505
|
+
That includes a Claude session still running under a name from before the
|
|
506
|
+
token, `<bot>.<session>`: `message to` answers the mailbox road for it, never
|
|
507
|
+
that name, until the session next starts a new conversation.
|
|
508
|
+
|
|
509
|
+
A session's mailbox is made the first time the kit starts it, and written in
|
|
498
510
|
the book beside its tab. It is an Orca Run rather than the session's tab,
|
|
499
511
|
because a tab's address dies with the tab and a Run does not — so a message sent
|
|
500
512
|
while a session is down is still there when it comes back.
|
|
501
513
|
|
|
514
|
+
Orca lets a tab bind a mailbox to itself and to no other, so the mailbox is made
|
|
515
|
+
and bound from inside the session's own tab: the launch line the kit types there
|
|
516
|
+
runs `obk session mailbox` before the harness. For the same reason a session's
|
|
517
|
+
mail is read in its own tab. `message check` from any other tab reads nothing,
|
|
518
|
+
binds nothing, and says so; the mail waits.
|
|
519
|
+
|
|
502
520
|
Nothing in a mailbox wakes anybody, so `message send` also types one line into
|
|
503
521
|
the receiver's tab telling it to look. Both harnesses take a typed line as the
|
|
504
522
|
next thing to do rather than cutting into what they are doing, which is the
|
|
@@ -527,6 +545,7 @@ failing quietly.
|
|
|
527
545
|
npm test # the whole suite, in a few minutes
|
|
528
546
|
npm run test:system # what the system tests would drive, and nothing else
|
|
529
547
|
npm run test:system -- --yes # drive them, on this machine, for real
|
|
548
|
+
npm run test:system -- --yes test/system/groom.test.js # only the ones you name
|
|
530
549
|
npm run mutate # the mutation audit, on what your branch changed
|
|
531
550
|
```
|
|
532
551
|
|
|
@@ -552,7 +571,9 @@ which Orca, which files, and what they will do to it — and then stops without
|
|
|
552
571
|
driving any of it, answering non-zero so that a run which did not run them
|
|
553
572
|
cannot be read as one that passed. `-- --yes` is how you say you meant it. When
|
|
554
573
|
Orca is not answering, the command says which Orca it asked and skips, rather
|
|
555
|
-
than report a kit that is not broken.
|
|
574
|
+
than report a kit that is not broken. Name files after it to run only those;
|
|
575
|
+
each must be one of the `*.test.js` files under `test/system/`, links followed,
|
|
576
|
+
and a name that is not is refused before anything runs.
|
|
556
577
|
|
|
557
578
|
`npm run mutate` runs [StrykerJS](https://stryker-mutator.io) over the
|
|
558
579
|
JavaScript this branch changed against `main` — committed, still in the working
|
package/SETUP.md
CHANGED
|
@@ -102,8 +102,9 @@ Ask both in one message, then wait.
|
|
|
102
102
|
|
|
103
103
|
**Where the bots folder goes.** An absolute path. It becomes a git repository of
|
|
104
104
|
theirs holding every bot's configuration: charters, rules, session settings, the
|
|
105
|
-
book of sessions. It holds none of the kit's code.
|
|
106
|
-
|
|
105
|
+
book of sessions. It holds none of the kit's code. The bots commit their own
|
|
106
|
+
changes to it; the kit's commands never commit, and pushing it somewhere is
|
|
107
|
+
theirs to decide.
|
|
107
108
|
|
|
108
109
|
**Which harness Bot Father runs on**, `claude` or `codex`. There is no default
|
|
109
110
|
and the kit will not guess. It is Bot Father's own harness only; each bot they
|
package/package.json
CHANGED
package/rules/commit.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: commit
|
|
3
|
+
title: Commit your changes
|
|
4
|
+
applies: all
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Commit your own changes to the bots repo often, as each piece of work there is
|
|
8
|
+
done: what you changed, in whatever folder it is, and what the kit wrote in
|
|
9
|
+
your own bot's folder (its files there and your book of sessions). Stage each
|
|
10
|
+
file by name and name them in the commit too (`git commit -- <files>`), so
|
|
11
|
+
nothing another session staged goes in with yours. What no bot owns (init's
|
|
12
|
+
files, the defaults, the user's rules and skill lists) is Bot Father's to
|
|
13
|
+
commit. Commit yours even when the repo has no commit yet or holds files that
|
|
14
|
+
are not yours; leave those to their owner.
|
|
15
|
+
|
|
16
|
+
Committing is not pushing: pushing is the user's call. A clone in your work dir
|
|
17
|
+
follows its own repo's rules. When a commit fails, for example on another
|
|
18
|
+
session's index lock, try again, and say so if it still fails.
|
|
@@ -214,8 +214,12 @@ index load at every start. Codex's memories belong to the user and are shared
|
|
|
214
214
|
by every Codex session, so a Codex bot cannot have its own; say so before
|
|
215
215
|
turning them on for one. It is a hand edit, and say that you made it.
|
|
216
216
|
|
|
217
|
-
After each change, commit what it changed in the bots folder
|
|
218
|
-
|
|
217
|
+
After each change, commit what it changed in the bots folder, naming each
|
|
218
|
+
file when you stage it and again in the commit. Commit with it what no bot
|
|
219
|
+
owns and nobody has committed yet: `init`'s files, `defaults.yaml`,
|
|
220
|
+
`skills.yaml`, and the user's `rules/` and `skills/`. What it changed
|
|
221
|
+
includes the folder of a bot it made or changed. The kit never commits by
|
|
222
|
+
itself.
|
|
219
223
|
|
|
220
224
|
Do the reversible part and stop at the line. Making a bot, adding a session
|
|
221
225
|
and linking skills add things and are safe to run again; closing a tab,
|
|
@@ -251,6 +251,7 @@ identifiers, counts and paths that only show the work; where evidence matters,
|
|
|
251
251
|
say where it is.
|
|
252
252
|
|
|
253
253
|
Say what you did not look at. Where you changed anything in the bots folder,
|
|
254
|
-
commit it
|
|
254
|
+
commit it, naming each file when you stage it and again in the commit; the
|
|
255
|
+
kit never commits by itself.
|
|
255
256
|
|
|
256
257
|
Sources and licences: [NOTICE.md](NOTICE.md).
|
|
@@ -63,7 +63,8 @@ them says so and reads less, not everything.
|
|
|
63
63
|
The open findings are what stops the same thing being reported every day:
|
|
64
64
|
before filing an issue or sending feedback, look there, and where it is already
|
|
65
65
|
open, add to it rather than raising it again. Take a finding off when it is
|
|
66
|
-
fixed or no longer true. Commit these
|
|
66
|
+
fixed or no longer true. Commit these at the end of each run, naming each
|
|
67
|
+
file when you stage it and again in the commit.
|
|
67
68
|
|
|
68
69
|
## Keep the run bounded
|
|
69
70
|
|
package/src/bot.js
CHANGED
|
@@ -59,7 +59,7 @@ export function botNames(bots) {
|
|
|
59
59
|
* link the user made takes it out of that folder; undefined when it stays in.
|
|
60
60
|
*
|
|
61
61
|
* The kit writes only in the bot folder, never in user-level settings (PRD 6.3,
|
|
62
|
-
* ADR
|
|
62
|
+
* ADR 0022), and a link would make the one the other without a word. So the
|
|
63
63
|
* answer is the file system's, not the spelling's: see `whereItLeads`.
|
|
64
64
|
*/
|
|
65
65
|
export function leadsOutside(home, target) {
|
|
@@ -413,6 +413,31 @@ export const SESSION_FIELDS = [
|
|
|
413
413
|
'name', 'harness', 'model', 'effort', 'context', 'approval', 'prompt', 'prompt_file', 'work_dir', 'extra_args',
|
|
414
414
|
];
|
|
415
415
|
|
|
416
|
+
/** Everything the top of a bot's own file can hold. */
|
|
417
|
+
const BOT_FIELDS = ['name', 'harness', 'charter', 'rules', 'skills', 'sessions', 'paused'];
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* The keys in a bot's file that the kit does not know, and so that nothing
|
|
421
|
+
* reads: a typo such as `efort` leaves its session on the harness's default
|
|
422
|
+
* while the file says otherwise (#273). Said, and never refused or taken out,
|
|
423
|
+
* because the file is the user's.
|
|
424
|
+
*/
|
|
425
|
+
export function unknownKeys(home, name = path.basename(home)) {
|
|
426
|
+
const file = path.join(home, BOT_YAML);
|
|
427
|
+
const unknown = (entry, known) => Object.keys(entry).filter((key) => !known.includes(key));
|
|
428
|
+
const sessionKeys = [...SESSION_FIELDS, 'paused'];
|
|
429
|
+
return [
|
|
430
|
+
...unknown(asMapping(parseYaml(file), file), BOT_FIELDS).map((key) => ({
|
|
431
|
+
where: file,
|
|
432
|
+
says: `${file} has a key the kit does not know, ${key}, so nothing reads it and ${name} runs as if it were not there. The keys a bot's file can have are ${BOT_FIELDS.join(', ')}.`,
|
|
433
|
+
})),
|
|
434
|
+
...readBot(home, name).sessions.flatMap((session) => unknown(session, sessionKeys).map((key) => ({
|
|
435
|
+
where: file,
|
|
436
|
+
says: `${file}: ${name}'s session ${session.name} has a setting the kit does not know, ${key}, so nothing reads it and the session runs as if it were not there. The settings a session can have are ${sessionKeys.join(', ')}.`,
|
|
437
|
+
}))),
|
|
438
|
+
];
|
|
439
|
+
}
|
|
440
|
+
|
|
416
441
|
/** A session's settings, in that order, without the ones left out. */
|
|
417
442
|
function ordered(session) {
|
|
418
443
|
const entry = {};
|
package/src/cli.js
CHANGED
|
@@ -17,7 +17,7 @@ import { grooming } from './groom.js';
|
|
|
17
17
|
import { checkHealth, orcaSettingFindings } from './health.js';
|
|
18
18
|
import { initBots } from './init.js';
|
|
19
19
|
import { APPROVALS, HARNESSES, ownCli, shellWord, workDirOf } from './launch.js';
|
|
20
|
-
import { checkMail, lookUp, sendMessage } from './message.js';
|
|
20
|
+
import { checkMail, lookUp, noMailboxYet, sendMessage } from './message.js';
|
|
21
21
|
import { orcaCli, orcaTrouble, RELOAD_LINE } from './orca.js';
|
|
22
22
|
import { pauseSessions, unpauseSessions } from './pause.js';
|
|
23
23
|
import { recordSession, SHELL_ENV, TAB_ENV } from './record.js';
|
|
@@ -28,7 +28,7 @@ import { buildAgents, buildRules, CODEX_CAP } from './rules.js';
|
|
|
28
28
|
import { addSkill, buildSkills, linkSkills, removeSkill } from './skills.js';
|
|
29
29
|
import { addSource, fetchSources } from './sources.js';
|
|
30
30
|
import { readUsage } from './usage.js';
|
|
31
|
-
import { BOT_FATHER, bringUp } from './up.js';
|
|
31
|
+
import { BOT_FATHER, bringUp, ownMailbox } from './up.js';
|
|
32
32
|
|
|
33
33
|
const USAGE = `obk — Orca Bot Kit.
|
|
34
34
|
|
|
@@ -186,6 +186,10 @@ Usage:
|
|
|
186
186
|
For the kit's own hook, not for typing: it reads
|
|
187
187
|
what the harness says about a session starting on
|
|
188
188
|
standard input and writes it into the book.
|
|
189
|
+
obk session mailbox --bots <path> --bot <bot> --session <name>
|
|
190
|
+
For the kit's own launch line, not for typing: run
|
|
191
|
+
in the session's own tab, it gives the session its
|
|
192
|
+
mailbox, or binds the one it has, to that tab.
|
|
189
193
|
obk --version Print the kit's version.
|
|
190
194
|
obk --help Print this text.
|
|
191
195
|
|
|
@@ -221,6 +225,7 @@ const COMMANDS = {
|
|
|
221
225
|
'message send': ['bots', 'to', 'subject'],
|
|
222
226
|
'message check': ['bots'],
|
|
223
227
|
'session record': ['bots', 'bot'],
|
|
228
|
+
'session mailbox': ['bots', 'bot', 'session'],
|
|
224
229
|
};
|
|
225
230
|
|
|
226
231
|
/** What each flag is for, in the sentence a caller reads when it is missing. */
|
|
@@ -409,7 +414,7 @@ const RECORD = 'session record';
|
|
|
409
414
|
* A hook runs inside the user's own session, so this one stays out of the way:
|
|
410
415
|
* it writes on standard output only what the harness is to read as JSON, and
|
|
411
416
|
* whatever goes wrong, it goes wrong quietly. A book left stale is a thing the
|
|
412
|
-
* health check finds later; a session disturbed is the user's work (ADR
|
|
417
|
+
* health check finds later; a session disturbed is the user's work (ADR 0022).
|
|
413
418
|
*/
|
|
414
419
|
async function record(bots, bot) {
|
|
415
420
|
try {
|
|
@@ -796,6 +801,17 @@ const commands = {
|
|
|
796
801
|
};
|
|
797
802
|
},
|
|
798
803
|
|
|
804
|
+
async 'session mailbox'(bots, values) {
|
|
805
|
+
const answer = await ownMailbox(bots, values.bot, values.session);
|
|
806
|
+
const who = `${answer.bot}/${answer.session}`;
|
|
807
|
+
const said = {
|
|
808
|
+
made: `${who} has its mailbox ${answer.mailbox}, made in this tab.`,
|
|
809
|
+
bound: `${who}'s mailbox ${answer.mailbox} is bound to this tab.`,
|
|
810
|
+
none: `${who} gets no mailbox: it is a Codex session with its sandbox switch off, which could not read one.`,
|
|
811
|
+
};
|
|
812
|
+
return { answer, lines: [said[answer.change]] };
|
|
813
|
+
},
|
|
814
|
+
|
|
799
815
|
'session change'(bots, values) {
|
|
800
816
|
if (values.harness !== undefined) {
|
|
801
817
|
throw new Error(`session change does not change a session's harness: its conversations belong to the harness they ran on. To move it, retire it with obk retire and add one on ${values.harness} with obk session add.`);
|
|
@@ -1180,6 +1196,9 @@ function tabLines({ bots, created, completed, rules, skills, tabs, paused = [],
|
|
|
1180
1196
|
for (const tab of tabs) {
|
|
1181
1197
|
lines.push(`${tab.created ? 'opened' : 'found '} ${tab.title} tab ${tab.tabId} terminal ${tab.terminal}`);
|
|
1182
1198
|
lines.push(...harnessLines(tab, bots));
|
|
1199
|
+
if (tab.noMailbox) {
|
|
1200
|
+
lines.push(` it has no mailbox: ${noMailboxYet({ bots, bot: tab.bot, session: tab.name })}`);
|
|
1201
|
+
}
|
|
1183
1202
|
}
|
|
1184
1203
|
for (const one of paused) {
|
|
1185
1204
|
const what = one.session === undefined ? one.bot : `${one.bot} ${one.session}`;
|
package/src/health.js
CHANGED
|
@@ -22,11 +22,12 @@ import { existsSync, readdirSync, realpathSync } from 'node:fs';
|
|
|
22
22
|
import path from 'node:path';
|
|
23
23
|
|
|
24
24
|
import { bookFile, readBook, sessionIdsIn, tabIdsIn } from './book.js';
|
|
25
|
-
import { botDir, botNames, botsDir, readBot } from './bot.js';
|
|
25
|
+
import { botDir, botNames, botsDir, readBot, unknownKeys } from './bot.js';
|
|
26
26
|
import { transcriptsIn } from './conversations.js';
|
|
27
27
|
import { hookTrouble } from './hooks.js';
|
|
28
|
-
import { bypassFlags, harnessOf, HARNESSES, ownCli, sessionTrouble, shellWord } from './launch.js';
|
|
29
|
-
import { frontOfTab, orcaDefaultArgs, projects, tabs } from './orca.js';
|
|
28
|
+
import { bypassFlags, harnessOf, HARNESSES, ownCli, sessionTrouble, SHELL_ENV, shellWord } from './launch.js';
|
|
29
|
+
import { frontOfTab, orcaDefaultArgs, projects, tabs, wordsOfProcess } from './orca.js';
|
|
30
|
+
import { TAB_ENV } from './record.js';
|
|
30
31
|
import { agentsTrouble, rulesStamp } from './rules.js';
|
|
31
32
|
import { settingsInUse } from './settings.js';
|
|
32
33
|
import { skillsTrouble } from './skills.js';
|
|
@@ -186,6 +187,7 @@ function aboutBot(bots, name, setups, sessions) {
|
|
|
186
187
|
|
|
187
188
|
const said = (kind) => ({ where, says }) => finding(kind, where, says, name);
|
|
188
189
|
return [
|
|
190
|
+
...unknownKeys(home, name).map(said('config')),
|
|
189
191
|
...sessionSettings(home, bot).map(said('config')),
|
|
190
192
|
...agentsTrouble(bots, home, bot).map(said('config')),
|
|
191
193
|
...hooksOf(bots, home, bot).map(said('config')),
|
|
@@ -214,8 +216,9 @@ const hooksOf = (bots, home, bot) => [...new Set(bot.sessions.map((session) => h
|
|
|
214
216
|
/**
|
|
215
217
|
* Each session of this bot running in a tab Orca has, with what it runs on set
|
|
216
218
|
* beside what the bot asks for now, added to `sessions`; a finding for each
|
|
217
|
-
* one that runs on something else (#271, #272)
|
|
218
|
-
*
|
|
219
|
+
* one that runs on something else (#271, #272), or whose harness the kit's
|
|
220
|
+
* launch line did not start (#318); and a finding for each one whose tab is
|
|
221
|
+
* open with its harness gone from it (#300).
|
|
219
222
|
*
|
|
220
223
|
* Two questions, each answered from where the answer is written. The settings
|
|
221
224
|
* from the harness's own record of the conversation the session is in now,
|
|
@@ -256,15 +259,7 @@ function runningOn(bots, home, bot, book, handles, sessions) {
|
|
|
256
259
|
const restart = `${shellWord(ownCli())} restart --bots ${shellWord(bots)} --bot ${bot.name} --session ${session.name}`;
|
|
257
260
|
const front = frontOfTab(handle);
|
|
258
261
|
if (front.front === 'shell') {
|
|
259
|
-
|
|
260
|
-
// there may be none to name: the harness can quit before it has one. Its
|
|
261
|
-
// harness is gone, so closing the tab ends nothing, and `up` then starts
|
|
262
|
-
// the session again as it does any closed tab with no conversation.
|
|
263
|
-
const up = `${shellWord(ownCli())} up --bots ${shellWord(bots)} --bot ${bot.name} --session ${session.name}`;
|
|
264
|
-
const back = typeof entry.session === 'string'
|
|
265
|
-
? `${restart} does`
|
|
266
|
-
: `nor does obk restart, which refuses to close a tab whose conversation the book does not name, and the book names none for it. Its harness has already quit, so close the tab in Orca yourself; then ${up} starts it again, with a new conversation and its duty. Any conversation it left in this bot's folder is noted in the book as unclaimed, for health to list`;
|
|
267
|
-
found.push(finding('session', entry.tab, `${bot.name}'s session ${session.name} is not running: its tab ${entry.tab} is open with only the tab's shell in front, so its harness quit or crashed. obk up finds the tab open and types nothing into it, so it does not bring the session back; ${back}.`, bot.name));
|
|
262
|
+
found.push(finding('session', entry.tab, `${bot.name}'s session ${session.name} is not running: its tab ${entry.tab} is open with only the tab's shell in front, so its harness quit or crashed. obk up finds the tab open and types nothing into it, so it does not bring the session back; ${restart} does.`, bot.name));
|
|
268
263
|
continue;
|
|
269
264
|
}
|
|
270
265
|
const harness = harnessOf(session, bot.harness);
|
|
@@ -277,8 +272,15 @@ function runningOn(bots, home, bot, book, handles, sessions) {
|
|
|
277
272
|
if (running !== 'yes') continue;
|
|
278
273
|
|
|
279
274
|
const off = Object.entries(settings).filter(([, one]) => one.state === 'mismatch');
|
|
280
|
-
|
|
281
|
-
|
|
275
|
+
const parts = off.map(([name, one]) => `${name}: bot.yaml asks for ${one.configured}, and it runs on ${one.observed}`).join('; ');
|
|
276
|
+
if (startedByKit(front.pid, entry.tab) === false) {
|
|
277
|
+
// Orca brings its tabs back by itself after a restart or an update, with
|
|
278
|
+
// a bare resume and none of the kit's launch line (#318). What health can
|
|
279
|
+
// see may still match, by the harness's own defaults; the rest of what
|
|
280
|
+
// bot.yaml asks for is not on it either way.
|
|
281
|
+
const drift = off.length === 0 ? '' : ` ${parts}, as the harness's own record of its conversation ${conversation} says.`;
|
|
282
|
+
found.push(finding('session', entry.tab, `${bot.name}'s session ${session.name} was not started by the kit: the harness in its tab ${entry.tab} carries nothing of the kit's launch line, as when Orca brings its tabs back by itself after a restart or an update. So it runs on the harness's own defaults and whatever Orca added, not on what ${path.join(home, 'bot.yaml')} asks for.${drift} ${restart} starts it on bot.yaml.`, bot.name));
|
|
283
|
+
} else if (off.length > 0) {
|
|
282
284
|
found.push(finding('session', file, `${bot.name}'s session ${session.name} does not run on what ${path.join(home, 'bot.yaml')} asks for. ${parts}. That is what the harness's own record of its conversation ${conversation} says. A session takes these when it starts, so if bot.yaml changed after it started, ${restart} starts it on them; if not, something else set them, such as a default of the harness's own or a change made inside the session.`, bot.name));
|
|
283
285
|
}
|
|
284
286
|
|
|
@@ -296,6 +298,19 @@ function runningOn(bots, home, bot, book, handles, sessions) {
|
|
|
296
298
|
return found;
|
|
297
299
|
}
|
|
298
300
|
|
|
301
|
+
/**
|
|
302
|
+
* Whether the kit's launch line started the process `pid` in the tab `tab`:
|
|
303
|
+
* true when it carries the marker the line puts there, false when it carries
|
|
304
|
+
* that tab's id and no marker, and undefined when its environment cannot be
|
|
305
|
+
* read (tech notes, section 1). Only a whole word counts: an argument, such as
|
|
306
|
+
* a start prompt, can hold anything.
|
|
307
|
+
*/
|
|
308
|
+
function startedByKit(pid, tab) {
|
|
309
|
+
const words = pid === undefined ? undefined : wordsOfProcess(pid);
|
|
310
|
+
if (words === undefined || !words.includes(`${TAB_ENV}=${tab}`)) return undefined;
|
|
311
|
+
return words.some((word) => new RegExp(`^${SHELL_ENV}=[0-9]+$`).test(word));
|
|
312
|
+
}
|
|
313
|
+
|
|
299
314
|
/**
|
|
300
315
|
* What Orca has of this bot against what the book says it should have: a
|
|
301
316
|
* session whose tab is gone, a conversation nobody claims, and a tab in the
|
|
@@ -364,7 +379,7 @@ function inOrca(bots, home, bot, setups, sessions) {
|
|
|
364
379
|
/**
|
|
365
380
|
* The conversations a harness has on record in this bot's folder that the book
|
|
366
381
|
* does not name anywhere. The hook is how an id reaches the book, and when it
|
|
367
|
-
* fails it fails quietly (ADR
|
|
382
|
+
* fails it fails quietly (ADR 0022), so this is where a stale book shows: the
|
|
368
383
|
* harness's own record set beside it (ADR 0012).
|
|
369
384
|
*/
|
|
370
385
|
function offTheBook(home, bot, book, onRecord) {
|
package/src/hooks.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The kit's own hook, in the bot's folder and nowhere else (ADR
|
|
1
|
+
// The kit's own hook, in the bot's folder and nowhere else (ADR 0022).
|
|
2
2
|
//
|
|
3
3
|
// Both harnesses read a hooks file out of the folder a session starts in, and
|
|
4
4
|
// every session starts at its bot home, so the bot folder is where the kit's
|
|
@@ -35,7 +35,7 @@ const TIMEOUT = 10;
|
|
|
35
35
|
* What the hook runs: the CLI that wrote it, by its own path, so the session
|
|
36
36
|
* reports to the kit that set it up and not to whatever `obk` PATH finds in the
|
|
37
37
|
* harness's shell (#220). `|| true` and a quiet stderr because a hook must never
|
|
38
|
-
* disturb the session it fires in (ADR
|
|
38
|
+
* disturb the session it fires in (ADR 0022): a kit that is not there, or a book
|
|
39
39
|
* that cannot be written, leaves the session alone and the health check finds
|
|
40
40
|
* the stale book later.
|
|
41
41
|
*/
|
|
@@ -124,7 +124,7 @@ export function hookTrouble(home, harness, { bots, bot }) {
|
|
|
124
124
|
/**
|
|
125
125
|
* What to say about a hooks file that a link takes out of the bot folder, or
|
|
126
126
|
* undefined when it stays in. The kit writes its hook in the bot folder and
|
|
127
|
-
* nowhere else (ADR
|
|
127
|
+
* nowhere else (ADR 0022); through a link like that it would be writing the
|
|
128
128
|
* user's own settings, so it does not, and says why.
|
|
129
129
|
*/
|
|
130
130
|
function outside(home, file) {
|
package/src/launch.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// The text goes into the tab's ordinary shell, so every word is quoted the way
|
|
11
11
|
// a shell needs it. `claude --model sonnet[1m]` would be a glob to zsh.
|
|
12
12
|
|
|
13
|
+
import { randomInt } from 'node:crypto';
|
|
13
14
|
import { readFileSync } from 'node:fs';
|
|
14
15
|
import path from 'node:path';
|
|
15
16
|
import { fileURLToPath } from 'node:url';
|
|
@@ -53,8 +54,22 @@ export const harnessOf = (session, botHarness) => (set(session.harness) ? sessio
|
|
|
53
54
|
* session writes to (ADR 0018). It goes on the launch line as `-n`, and it is
|
|
54
55
|
* re-applied every time the session is started: a resume keeps the name by
|
|
55
56
|
* itself (tech notes, section 2), and the kit does not depend on that.
|
|
57
|
+
*
|
|
58
|
+
* `<bot>.<session>` alone is not an address: every fleet with that bot and
|
|
59
|
+
* session answers to it, old runs and throwaway test fleets included, and
|
|
60
|
+
* Claude Code refuses a send to a name more than one session holds (#286). So
|
|
61
|
+
* the name ends in a token of its own, made for each new conversation: eight
|
|
62
|
+
* letters and digits, 36^8 names, so that no two conversations draw the same
|
|
63
|
+
* one by chance.
|
|
56
64
|
*/
|
|
57
|
-
export const addressOf = (bot, session) =>
|
|
65
|
+
export const addressOf = (bot, session) =>
|
|
66
|
+
`${bot}.${session}.${Array.from({ length: 8 }, () => randomInt(36).toString(36)).join('')}`;
|
|
67
|
+
|
|
68
|
+
/** Whether `address` is one `addressOf` made for this bot and session. */
|
|
69
|
+
export function isAddressOf(bot, session, address) {
|
|
70
|
+
const own = `${bot}.${session}.`;
|
|
71
|
+
return typeof address === 'string' && address.startsWith(own) && /^[a-z0-9]{8}$/.test(address.slice(own.length));
|
|
72
|
+
}
|
|
58
73
|
|
|
59
74
|
/**
|
|
60
75
|
* The Codex setting without which a bot cannot reach Orca at all.
|
|
@@ -277,6 +292,17 @@ const quoted = (word) => (/^[A-Za-z0-9,._+:@%/=-]+$/.test(word) ? word : `'${wor
|
|
|
277
292
|
/** The same, for anything else of the kit's that has to build a shell line. */
|
|
278
293
|
export const shellWord = quoted;
|
|
279
294
|
|
|
295
|
+
/**
|
|
296
|
+
* What a launch line starts with for a session that can have a mailbox: the
|
|
297
|
+
* kit itself, run by the new tab's shell before the harness, giving the session
|
|
298
|
+
* its mailbox bound to that tab. Orca 1.4.210 lets a process in a tab bind a
|
|
299
|
+
* Run to that tab and to no other, so the `obk` that opened the tab cannot do
|
|
300
|
+
* it (#317). The line joins it with `;`, so the harness starts whatever became
|
|
301
|
+
* of it.
|
|
302
|
+
*/
|
|
303
|
+
export const mailboxStep = (bots, bot, session) =>
|
|
304
|
+
[ownCli(), 'session', 'mailbox', '--bots', bots, '--bot', bot, '--session', session].map(quoted).join(' ');
|
|
305
|
+
|
|
280
306
|
/**
|
|
281
307
|
* The CLI that is running, by the path it was started by: the kit calls itself
|
|
282
308
|
* back with this, never with the bare name `obk`, because the bare name is
|
package/src/message.js
CHANGED
|
@@ -20,8 +20,8 @@ import path from 'node:path';
|
|
|
20
20
|
|
|
21
21
|
import { readBook } from './book.js';
|
|
22
22
|
import { botDir, botNames, readBot } from './bot.js';
|
|
23
|
-
import { harnessOf, ownCli, reachesMail, shellWord } from './launch.js';
|
|
24
|
-
import { ackMailbox, coordinatorOf, postMessage, readMailbox, tabs, tabToTypeInto, typeIntoTab, useMailbox } from './orca.js';
|
|
23
|
+
import { harnessOf, isAddressOf, ownCli, reachesMail, shellWord } from './launch.js';
|
|
24
|
+
import { ackMailbox, coordinatorOf, postMessage, readMailbox, tabs, tabToTypeInto, TERMINAL_ENV, typeIntoTab, useMailbox } from './orca.js';
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
27
|
* How much of a message travels as itself. Above this it is written to a file
|
|
@@ -67,7 +67,10 @@ export function findSession(bots, target) {
|
|
|
67
67
|
session: session.name,
|
|
68
68
|
harness,
|
|
69
69
|
approval: classOf(session),
|
|
70
|
-
|
|
70
|
+
// Only a name the kit gave this session is an address. A bare
|
|
71
|
+
// `<bot>.<session>`, from before #286, is shared with other fleets'
|
|
72
|
+
// sessions, and Claude Code refuses a send to it.
|
|
73
|
+
address: isAddressOf(bot.name, session.name, held.address) ? held.address : undefined,
|
|
71
74
|
mailbox: typeof held.mailbox === 'string' ? held.mailbox : undefined,
|
|
72
75
|
tab: typeof held.tab === 'string' ? held.tab : undefined,
|
|
73
76
|
trouble: whyNotReachable(bot.name, session, harness, held),
|
|
@@ -91,7 +94,8 @@ export function roadBetween(from, to) {
|
|
|
91
94
|
address: to.mailbox === undefined ? undefined : `run:${to.mailbox}`,
|
|
92
95
|
// A Claude pair that the native road cannot carry, because the receiver is
|
|
93
96
|
// running under no name the kit gave it: it was started before the kit
|
|
94
|
-
// named sessions,
|
|
97
|
+
// named sessions, or before it gave them names of their own (#286), and
|
|
98
|
+
// nothing renames a live harness. The mailbox is the
|
|
95
99
|
// road that exists, and the caller is told why it is the one being used
|
|
96
100
|
// rather than left to wonder (review of PR #132, finding 1).
|
|
97
101
|
unnamed: pair ? true : undefined,
|
|
@@ -147,7 +151,9 @@ export function sendMessage(bots, { to: target, from: sender, tab, subject, text
|
|
|
147
151
|
return {
|
|
148
152
|
...answer,
|
|
149
153
|
sent: false,
|
|
150
|
-
trouble:
|
|
154
|
+
trouble: from.tab === undefined
|
|
155
|
+
? `${from.bot}/${from.session} has no mailbox of its own yet, so a reply would have nowhere to go. Bring it up first: ${upCommand(from)}`
|
|
156
|
+
: `${from.bot}/${from.session} has no mailbox of its own, so a reply would have nowhere to go: ${noMailboxYet(from)}`,
|
|
151
157
|
};
|
|
152
158
|
}
|
|
153
159
|
|
|
@@ -200,6 +206,20 @@ export function checkMail(bots, { bot: botName, session: sessionName, tab, peek
|
|
|
200
206
|
// whatever its mailbox is bound to, a closed tab included, and nothing is
|
|
201
207
|
// bound: the session's tab is bound again when it is back up (issue #249).
|
|
202
208
|
const live = who.tab === undefined ? undefined : tabs(who.home).find((tab) => tab.tabId === who.tab)?.handle;
|
|
209
|
+
// Orca 1.4.210 lets a process in a tab bind and read as that tab and no
|
|
210
|
+
// other, and refuses the rest with nothing done (#317). So from inside any
|
|
211
|
+
// other tab, the kit asks nothing and says where the mail can be read.
|
|
212
|
+
const caller = process.env[TERMINAL_ENV];
|
|
213
|
+
if (caller !== undefined && caller !== live) {
|
|
214
|
+
return {
|
|
215
|
+
bots,
|
|
216
|
+
bot: who.bot,
|
|
217
|
+
session: who.session,
|
|
218
|
+
mailbox: who.mailbox,
|
|
219
|
+
messages: [],
|
|
220
|
+
trouble: `${who.bot}/${who.session}'s mail can be read only in its own tab: Orca lets a tab bind and read its own mailbox and no other. Nothing was read, and its mail is still waiting.`,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
203
223
|
if (live !== undefined) useMailbox(who.mailbox, live);
|
|
204
224
|
const handle = live ?? coordinatorOf(who.mailbox);
|
|
205
225
|
if (handle === undefined) {
|
|
@@ -417,5 +437,22 @@ function whyNotReachable(bot, session, harness, held) {
|
|
|
417
437
|
return `${bot}/${session.name} is a Codex session whose extra arguments turn sandbox_workspace_write.network_access off. Orca is out of reach from inside that sandbox, so it can neither be written to nor read its own mail. Take that argument out, or write to it another way.`;
|
|
418
438
|
}
|
|
419
439
|
|
|
420
|
-
const notUpYet = (who) =>
|
|
421
|
-
`${who.bot}/${who.session} has no mailbox yet: it has never been brought up. Start it and it gets one: ${
|
|
440
|
+
const notUpYet = (who) => (who.tab === undefined
|
|
441
|
+
? `${who.bot}/${who.session} has no mailbox yet: it has never been brought up. Start it and it gets one: ${upCommand(who)} --session ${shellWord(who.session)}`
|
|
442
|
+
: `${who.bot}/${who.session} has no mailbox: ${noMailboxYet(who)}`);
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Why a session the kit has started has no mailbox, and what gets it one. Its
|
|
446
|
+
* mailbox is made by the step at the head of its launch line, in its own tab
|
|
447
|
+
* (#317), so `up` of a session that is running gives it none: starting it
|
|
448
|
+
* again does, and `up` does when its tab is closed.
|
|
449
|
+
*/
|
|
450
|
+
export const noMailboxYet = (who) =>
|
|
451
|
+
`its tab did not make one when it started, and the tab shows why. Start it again and its tab makes one: ${restartCommand(who)} (or, if its tab is closed: ${upCommand(who)} --session ${shellWord(who.session)})`;
|
|
452
|
+
|
|
453
|
+
/** The command that starts one session again, in a new tab. */
|
|
454
|
+
const restartCommand = (who) =>
|
|
455
|
+
`${shellWord(ownCli())} restart --bots ${shellWord(who.bots)} --bot ${shellWord(who.bot)} --session ${shellWord(who.session)}`;
|
|
456
|
+
|
|
457
|
+
/** The command that brings a bot's sessions up, for the caller to finish with a session or not. */
|
|
458
|
+
const upCommand = (who) => `${shellWord(ownCli())} up --bots ${shellWord(who.bots)} --bot ${shellWord(who.bot)}`;
|
package/src/orca.js
CHANGED
|
@@ -24,9 +24,11 @@ export const orcaCli = () => process.env.OBK_ORCA || BUILT_IN;
|
|
|
24
24
|
/**
|
|
25
25
|
* Run one Orca command and give back its `result`.
|
|
26
26
|
* Every call asks for `--json`; anything else would be text to guess at.
|
|
27
|
+
* With `timeoutMs`, Orca is given that long to answer, and the call is ended
|
|
28
|
+
* and refused if it has not.
|
|
27
29
|
*/
|
|
28
|
-
export function orca(args) {
|
|
29
|
-
const answer = ask(args);
|
|
30
|
+
export function orca(args, { timeoutMs } = {}) {
|
|
31
|
+
const answer = ask(args, timeoutMs);
|
|
30
32
|
if (answer.ok !== true) {
|
|
31
33
|
// Orca's own words: it knows what went wrong, and the caller is an LLM
|
|
32
34
|
// that can act on them.
|
|
@@ -35,6 +37,9 @@ export function orca(args) {
|
|
|
35
37
|
return answer.result;
|
|
36
38
|
}
|
|
37
39
|
|
|
40
|
+
/** What an error carries when Orca was given a time and did not answer in it. */
|
|
41
|
+
export const TIMED_OUT = 'orca_timed_out';
|
|
42
|
+
|
|
38
43
|
/** Orca's refusal as an error, with its code on it, so a caller can tell a stale handle from the rest (#294). */
|
|
39
44
|
const refusal = (args, answer) => Object.assign(
|
|
40
45
|
new Error(`Orca refused ${args.join(' ')}: ${answer.error?.message ?? 'no reason given'}`),
|
|
@@ -42,8 +47,11 @@ const refusal = (args, answer) => Object.assign(
|
|
|
42
47
|
);
|
|
43
48
|
|
|
44
49
|
/** One Orca command, and the whole envelope back, refusals included. */
|
|
45
|
-
function ask(args) {
|
|
46
|
-
const asked = spawnSync(orcaCli(), [...args, '--json'], { encoding: 'utf8' });
|
|
50
|
+
function ask(args, timeoutMs) {
|
|
51
|
+
const asked = spawnSync(orcaCli(), [...args, '--json'], { encoding: 'utf8', ...(timeoutMs === undefined ? {} : { timeout: timeoutMs }) });
|
|
52
|
+
if (asked.error?.code === 'ETIMEDOUT') {
|
|
53
|
+
throw Object.assign(new Error(`Orca did not answer ${args.join(' ')} within ${timeoutMs / 1000} seconds.`), { code: TIMED_OUT });
|
|
54
|
+
}
|
|
47
55
|
if (asked.error) {
|
|
48
56
|
throw new Error(`could not run Orca at ${orcaCli()}: ${asked.error.message}`);
|
|
49
57
|
}
|
|
@@ -338,8 +346,8 @@ const psCli = () => process.env.OBK_PS || '/bin/ps';
|
|
|
338
346
|
|
|
339
347
|
/**
|
|
340
348
|
* Who holds the terminal of the pane `ptyId`: `{ front: 'shell' }`,
|
|
341
|
-
* `{ front: 'program', command }` with the name of the process
|
|
342
|
-
* group in front, or `{ unreadable: <why> }`.
|
|
349
|
+
* `{ front: 'program', command, pid }` with the name and pid of the process
|
|
350
|
+
* leading the group in front, or `{ unreadable: <why> }`.
|
|
343
351
|
*
|
|
344
352
|
* Orca gives the pane's pid in `diagnostics memory` and nowhere else, and `ps`
|
|
345
353
|
* gives that pid's terminal's foreground process group (ADR 0021).
|
|
@@ -366,7 +374,20 @@ function frontOf(ptyId) {
|
|
|
366
374
|
const front = psLine(own.tpgid);
|
|
367
375
|
if (front === undefined) return { unreadable: `ps could not read the process in front, pid ${own.tpgid}` };
|
|
368
376
|
const shell = front.ppid === pane && path.basename(own.comm) === 'login';
|
|
369
|
-
return shell ? { front: 'shell' } : { front: 'program', command: path.basename(front.comm) };
|
|
377
|
+
return shell ? { front: 'shell' } : { front: 'program', command: path.basename(front.comm), pid: own.tpgid };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* The process `pid` as `ps -E` gives it, word by word: its command, its
|
|
382
|
+
* arguments, then its environment as `NAME=value` words. Undefined when it
|
|
383
|
+
* cannot be read. macOS gives a same-user harness's environment this way (tech
|
|
384
|
+
* notes, section 1); nothing in it says where the arguments end, so a caller
|
|
385
|
+
* looks for one whole word it knows.
|
|
386
|
+
*/
|
|
387
|
+
export function wordsOfProcess(pid) {
|
|
388
|
+
const asked = spawnSync(psCli(), ['-E', '-ww', '-o', 'command=', '-p', String(pid)], { encoding: 'utf8' });
|
|
389
|
+
if (asked.error || asked.status !== 0) return undefined;
|
|
390
|
+
return asked.stdout.trim().split(/\s+/);
|
|
370
391
|
}
|
|
371
392
|
|
|
372
393
|
/** One process as `ps` gives it, read only: `{ ppid, tpgid, comm }`, or undefined. */
|
|
@@ -418,37 +439,41 @@ export function typeIntoTab(handle, text) {
|
|
|
418
439
|
/** How long a re-issued line is given to be submitted before Orca gives up on it. */
|
|
419
440
|
const SUBMIT_WAIT_S = 5;
|
|
420
441
|
|
|
442
|
+
/** Where Orca names the terminal a process runs in, in every pane it opens. */
|
|
443
|
+
export const TERMINAL_ENV = 'ORCA_TERMINAL_HANDLE';
|
|
444
|
+
|
|
421
445
|
/**
|
|
422
446
|
* A mailbox of one session's own: an Orca Run, which is a name and an inbox and
|
|
423
447
|
* nothing else — it schedules nothing and runs nobody. The objective is what a
|
|
424
448
|
* person sees in `orca orchestration run-list`, so it says whose it is.
|
|
425
449
|
*
|
|
426
|
-
* Bound to
|
|
427
|
-
* tells a Run's coordinator terminal about its mail and nobody else,
|
|
428
|
-
*
|
|
429
|
-
*
|
|
430
|
-
*
|
|
450
|
+
* Bound to the terminal this process runs in, which has to be the session's own
|
|
451
|
+
* tab: Orca tells a Run's coordinator terminal about its mail and nobody else,
|
|
452
|
+
* and one terminal holds one Run. Orca 1.4.210 lets a process in a tab bind a
|
|
453
|
+
* Run to that tab and to no other, so this is called from inside the session's
|
|
454
|
+
* tab and names no `--from` (tech notes, section 1; #317).
|
|
431
455
|
*/
|
|
432
|
-
export const makeMailbox = (objective,
|
|
433
|
-
orca(['orchestration', 'run-create', '--objective', `obk ${objective}
|
|
456
|
+
export const makeMailbox = (objective, options) =>
|
|
457
|
+
orca(['orchestration', 'run-create', '--objective', `obk ${objective}`], options).run.id;
|
|
434
458
|
|
|
435
459
|
/**
|
|
436
460
|
* Bind a mailbox to the terminal `handle`, which makes it the Run's coordinator
|
|
437
461
|
* and its one reader: Orca refuses a `check` from any other terminal with
|
|
438
462
|
* `consumer_fenced`. A read itself binds nothing, so this is for a session's own
|
|
439
463
|
* live tab, never for whoever is reading (tech notes, section 1). Without a
|
|
440
|
-
* handle it binds this process's own terminal.
|
|
464
|
+
* handle it binds this process's own terminal; on Orca 1.4.210 a process in a
|
|
465
|
+
* tab may name no other (#317).
|
|
441
466
|
*/
|
|
442
|
-
export const useMailbox = (id, handle) =>
|
|
443
|
-
orca(['orchestration', 'run-use', '--id', id, ...(handle === undefined ? [] : ['--from', handle])]).run;
|
|
467
|
+
export const useMailbox = (id, handle, options) =>
|
|
468
|
+
orca(['orchestration', 'run-use', '--id', id, ...(handle === undefined ? [] : ['--from', handle])], options).run;
|
|
444
469
|
|
|
445
470
|
/**
|
|
446
471
|
* The terminal a mailbox is bound to, or undefined when it has none. A closed
|
|
447
472
|
* tab keeps its binding, and a read as its handle still works: a read binds
|
|
448
473
|
* nothing, only `run-use` does (tech notes, section 1).
|
|
449
474
|
*/
|
|
450
|
-
export const coordinatorOf = (id) =>
|
|
451
|
-
orca(['orchestration', 'run-show', '--id', id]).run.coordinator_handle ?? undefined;
|
|
475
|
+
export const coordinatorOf = (id, options) =>
|
|
476
|
+
orca(['orchestration', 'run-show', '--id', id], options).run.coordinator_handle ?? undefined;
|
|
452
477
|
|
|
453
478
|
/** Queue one message. `to` and `from` are mailboxes, written `run:<id>`. */
|
|
454
479
|
export function postMessage({ to, from, subject, body, type = 'status', thread }) {
|
package/src/record.js
CHANGED
|
@@ -166,22 +166,26 @@ function unclaimedFor(book, home, bot, tabId, id) {
|
|
|
166
166
|
}
|
|
167
167
|
|
|
168
168
|
/**
|
|
169
|
-
* Whether the harness that ran this hook is the session's own — the one the
|
|
170
|
-
*
|
|
169
|
+
* Whether the harness that ran this hook is the session's own — the one the
|
|
170
|
+
* tab's shell started — rather than a harness started inside the session.
|
|
171
171
|
*
|
|
172
172
|
* Proved live on both harnesses (tech notes, sections 2 and 3): a hook's process
|
|
173
173
|
* is the harness's own child, or the child of the shell the harness ran the hook
|
|
174
174
|
* command through. Either way the harness itself is the process just above, and
|
|
175
|
-
* the session's harness is the child of the shell
|
|
176
|
-
*
|
|
177
|
-
*
|
|
175
|
+
* the session's harness is the child of the tab's shell. A harness started
|
|
176
|
+
* inside the session sits further down a longer chain, under whatever shell the
|
|
177
|
+
* agent ran it from, so it can never answer this.
|
|
178
|
+
*
|
|
179
|
+
* The launch line names the tab's shell. A tab Orca brought back by itself has
|
|
180
|
+
* no launch line (#318), and there the tab's shell is the one `login` started
|
|
181
|
+
* for the pane, which is where Orca's own resume puts the harness too (tech
|
|
182
|
+
* notes, section 1).
|
|
178
183
|
*
|
|
179
184
|
* Anything it cannot establish is a no: a report that cannot be shown to be the
|
|
180
185
|
* session's own is not written down.
|
|
181
186
|
*/
|
|
182
187
|
export function startedTheSession(shellPid, pid = process.pid) {
|
|
183
|
-
if (!/^[0-9]+$/.test(String(shellPid
|
|
184
|
-
const shell = Number(shellPid);
|
|
188
|
+
if (shellPid !== undefined && !/^[0-9]+$/.test(String(shellPid))) return false;
|
|
185
189
|
|
|
186
190
|
const tree = processTree();
|
|
187
191
|
if (tree === undefined) return false;
|
|
@@ -190,11 +194,21 @@ export function startedTheSession(shellPid, pid = process.pid) {
|
|
|
190
194
|
const parent = mine === undefined ? undefined : tree.get(mine.ppid);
|
|
191
195
|
if (parent === undefined) return false;
|
|
192
196
|
|
|
193
|
-
if (
|
|
194
|
-
|
|
197
|
+
if (shellPid !== undefined) {
|
|
198
|
+
const shell = Number(shellPid);
|
|
199
|
+
if (parent.ppid === shell) return true;
|
|
200
|
+
if (!isShell(parent.command)) return false;
|
|
201
|
+
|
|
202
|
+
const above = tree.get(parent.ppid);
|
|
203
|
+
return above !== undefined && above.ppid === shell;
|
|
204
|
+
}
|
|
195
205
|
|
|
196
|
-
|
|
197
|
-
|
|
206
|
+
// With no shell named, which process is the harness has to be settled first:
|
|
207
|
+
// the one above the shell the hook ran through, or the hook's own parent.
|
|
208
|
+
const harness = isShell(parent.command) ? tree.get(parent.ppid) : parent;
|
|
209
|
+
const shell = harness === undefined ? undefined : tree.get(harness.ppid);
|
|
210
|
+
const pane = shell === undefined ? undefined : tree.get(shell.ppid);
|
|
211
|
+
return pane !== undefined && path.basename(pane.command) === 'login';
|
|
198
212
|
}
|
|
199
213
|
|
|
200
214
|
/** Every process on the machine, by pid: who its parent is and what it runs. */
|
package/src/restart.js
CHANGED
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
// else open in a bot's project, belongs to the user (PRD 6.2).
|
|
16
16
|
// 2. Never a tab whose conversation the kit could not name again. A live tab
|
|
17
17
|
// with no session id in the book is refused before anything is touched:
|
|
18
|
-
// the close would be the end of that conversation.
|
|
18
|
+
// the close would be the end of that conversation. Unless only its shell
|
|
19
|
+
// is in front: its harness has quit, so the close ends nothing, and `up`
|
|
20
|
+
// starts the session fresh as it does any closed tab with no id (#303).
|
|
19
21
|
// 3. One tab at a time, by its own handle. Orca's `--worktree <sel> --all`
|
|
20
22
|
// ends every tab of a project and is never called from anywhere in the kit.
|
|
21
23
|
//
|
|
@@ -32,7 +34,7 @@ import { setTimeout as pause } from 'node:timers/promises';
|
|
|
32
34
|
import { bookFile, readBook } from './book.js';
|
|
33
35
|
import { botDir, readBot } from './bot.js';
|
|
34
36
|
import { ownCli, shellWord } from './launch.js';
|
|
35
|
-
import { closeTab, findProject, tabs } from './orca.js';
|
|
37
|
+
import { closeTab, findProject, frontOfTab, tabs } from './orca.js';
|
|
36
38
|
import { botsNamed, bringUp, prepareBots, sessionsOf } from './up.js';
|
|
37
39
|
|
|
38
40
|
/**
|
|
@@ -65,7 +67,7 @@ export async function restartSessions(bots, { bot: name, session: onlySession }
|
|
|
65
67
|
throw new Error(`${name} would not come up again as it is, so nothing was closed. ${prepared.rules[0].trouble}`);
|
|
66
68
|
}
|
|
67
69
|
|
|
68
|
-
const going = tabsToClose(bots, name, home, sessions.filter((session) => session.paused !== true));
|
|
70
|
+
const going = tabsToClose(bots, name, home, sessions.filter((session) => session.paused !== true), { quit: true });
|
|
69
71
|
const closed = await closeTabs(home, going, bots, name);
|
|
70
72
|
return { closed, ...(await bringUp(bots, { bot: name, session: onlySession })) };
|
|
71
73
|
}
|
|
@@ -75,9 +77,11 @@ export async function restartSessions(bots, { bot: name, session: onlySession }
|
|
|
75
77
|
* has been judged safe to close: a live tab whose conversation the book cannot
|
|
76
78
|
* name refuses the lot, before anything is touched. With `keepless`, a tab is
|
|
77
79
|
* closed whatever the book knows, for a caller that is ending the session on
|
|
78
|
-
* purpose.
|
|
80
|
+
* purpose. With `quit`, a tab whose harness has quit to its shell is closed
|
|
81
|
+
* too, for a caller that starts the session again: there is nothing in it to
|
|
82
|
+
* lose. A program in front, or a front that cannot be read, still refuses.
|
|
79
83
|
*/
|
|
80
|
-
export function tabsToClose(bots, name, home, sessions, { keepless = false } = {}) {
|
|
84
|
+
export function tabsToClose(bots, name, home, sessions, { keepless = false, quit = false } = {}) {
|
|
81
85
|
const book = readBook(home);
|
|
82
86
|
|
|
83
87
|
// Orca is asked what the project holds only when it has one: a folder Orca
|
|
@@ -96,7 +100,7 @@ export function tabsToClose(bots, name, home, sessions, { keepless = false } = {
|
|
|
96
100
|
// Nothing of this session's is open: there is nothing to close, and `up`
|
|
97
101
|
// brings it back the way it brings back a tab the user closed themselves.
|
|
98
102
|
if (tab === undefined) continue;
|
|
99
|
-
if (keepless || typeof was.session === 'string') going.push({ name: session.name, tab });
|
|
103
|
+
if (keepless || typeof was.session === 'string' || (quit && frontOfTab(tab.handle).front === 'shell')) going.push({ name: session.name, tab });
|
|
100
104
|
else refusals.push(cannotComeBack(bots, name, session.name, was));
|
|
101
105
|
}
|
|
102
106
|
|
package/src/skills.js
CHANGED
|
@@ -292,7 +292,7 @@ export function unlinkSkills(home) {
|
|
|
292
292
|
* user made, or one of the kit's that they have since repointed, is theirs.
|
|
293
293
|
*
|
|
294
294
|
* It is the kit's own file and lives beside the hook files it already keeps in
|
|
295
|
-
* the bot folder (ADR
|
|
295
|
+
* the bot folder (ADR 0022). A record that cannot be read is treated as an
|
|
296
296
|
* empty one: the worst that follows is that the kit leaves alone something it
|
|
297
297
|
* would otherwise have tidied up, which is the safe way round.
|
|
298
298
|
*/
|
package/src/up.js
CHANGED
|
@@ -12,8 +12,9 @@ import { forgetClaimed, forgetSession, readBook, sessionIdsIn, tabIdsIn, updateB
|
|
|
12
12
|
import { botDir, botNames, displayName, readBot } from './bot.js';
|
|
13
13
|
import { conversationsIn, hasConversation, heldAsUserTurn, transcriptsIn } from './conversations.js';
|
|
14
14
|
import { installHook } from './hooks.js';
|
|
15
|
-
import { addressOf, harnessOf, isShortPrompt, launchCommand, reachesMail, sessionTrouble, startPrompt, workDirOf } from './launch.js';
|
|
16
|
-
import { asFolderProject, findProject, harnessInTab, makeMailbox, makeProject, openTab, retitleTab, tabs, tellWindow, typeIntoTab, useMailbox } from './orca.js';
|
|
15
|
+
import { addressOf, harnessOf, isAddressOf, isShortPrompt, launchCommand, mailboxStep, reachesMail, sessionTrouble, startPrompt, workDirOf } from './launch.js';
|
|
16
|
+
import { asFolderProject, coordinatorOf, findProject, harnessInTab, makeMailbox, makeProject, openTab, retitleTab, tabs, TERMINAL_ENV, TIMED_OUT, tellWindow, typeIntoTab, useMailbox } from './orca.js';
|
|
17
|
+
import { TAB_ENV } from './record.js';
|
|
17
18
|
import { buildAgents, rulesStamp } from './rules.js';
|
|
18
19
|
import { linkSkills } from './skills.js';
|
|
19
20
|
|
|
@@ -93,7 +94,7 @@ export async function bringUp(bots, { bot: onlyBot, session: onlySession } = {})
|
|
|
93
94
|
*
|
|
94
95
|
* Separate from the tabs because the order matters twice over. A session reads
|
|
95
96
|
* its rules, its skills and its hooks as it comes up, so all three have to be
|
|
96
|
-
* there before the tab is (PRD 6.6, ADR
|
|
97
|
+
* there before the tab is (PRD 6.6, ADR 0022) — and a restart closes a tab in
|
|
97
98
|
* between, so everything that can refuse must have refused before that. It
|
|
98
99
|
* refuses by throwing, exactly as `up` always has; a bot it leaves out of
|
|
99
100
|
* `running` is one whose sessions must not be started.
|
|
@@ -139,7 +140,7 @@ export function prepareBots(bots, names, onlySession) {
|
|
|
139
140
|
|
|
140
141
|
// And the kit's hook goes into every bot folder before Orca is asked for
|
|
141
142
|
// anything, for the same reason: a harness reads its hooks when it comes up,
|
|
142
|
-
// so one written later would miss the session it was written for (ADR
|
|
143
|
+
// so one written later would miss the session it was written for (ADR 0022),
|
|
143
144
|
// and a bot folder the kit cannot write it into stops the run with nothing
|
|
144
145
|
// opened anywhere. Only the harnesses a bot actually runs on; a bot with no
|
|
145
146
|
// sessions gets none.
|
|
@@ -237,20 +238,21 @@ async function bringUpSession(bots, home, live, session, bot, title) {
|
|
|
237
238
|
const tabTitle = `${title} ${session.name}`;
|
|
238
239
|
|
|
239
240
|
if (known) {
|
|
240
|
-
//
|
|
241
|
-
//
|
|
241
|
+
// No name, and no mailbox from here. The name is the one thing the kit
|
|
242
|
+
// cannot give a session that is already running: `-n` goes on the
|
|
242
243
|
// launch line, this session was launched without one, and nothing renames a
|
|
243
244
|
// live harness. Writing the name down here would advertise an address that
|
|
244
245
|
// answers to nobody (review of PR #132, finding 1). It gets one the next
|
|
245
|
-
// time it starts, which is the next time the kit types its launch line.
|
|
246
|
-
|
|
246
|
+
// time it starts, which is the next time the kit types its launch line. So
|
|
247
|
+
// does a mailbox, if its book has none: that is made from inside the tab
|
|
248
|
+
// the session starts in, and nothing is typed into this one (#317).
|
|
247
249
|
|
|
248
250
|
// Whatever runs in the tab may have rewritten its title. The kit writes its
|
|
249
251
|
// own back, and reports that one rather than the name Orca last saw: the id
|
|
250
252
|
// is the session, and a title is only ever set. Nothing is typed into a tab
|
|
251
253
|
// that is already there — the session is in the middle of its work.
|
|
252
254
|
retitleTab(known.handle, tabTitle);
|
|
253
|
-
return entry({ ...known, title: tabTitle }, { bot: bot.name, name: session.name, created: false });
|
|
255
|
+
return entry({ ...known, title: tabTitle }, { bot: bot.name, name: session.name, created: false, noMailbox: lacksMailbox(home, session, harness) });
|
|
254
256
|
}
|
|
255
257
|
|
|
256
258
|
// Everything that can be refused is settled before Orca is asked for
|
|
@@ -264,7 +266,7 @@ async function bringUpSession(bots, home, live, session, bot, title) {
|
|
|
264
266
|
// Asked before the book is held, because it reads a folder of the harness's
|
|
265
267
|
// own files: nothing slow happens under the lock.
|
|
266
268
|
let which = whichConversation(book, home, bot, session, was, harness);
|
|
267
|
-
const launchOf = (chosen) => launchFor(bots, bot, session, harness, home, workDir, chosen.resume);
|
|
269
|
+
const launchOf = (chosen) => launchFor(bots, bot, session, harness, home, workDir, chosen.resume, was?.address);
|
|
268
270
|
let launch = launchOf(which);
|
|
269
271
|
|
|
270
272
|
// A work dir is a plain folder, made for the session before it is told about
|
|
@@ -322,13 +324,12 @@ async function bringUpSession(bots, home, live, session, bot, title) {
|
|
|
322
324
|
// the harness as the tab's own command times out and leaves a dead tab.
|
|
323
325
|
typeIntoTab(made.handle, launch.command);
|
|
324
326
|
|
|
325
|
-
// Now the session has an address, and not before:
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
|
|
331
|
-
await ensureMailbox(home, bot, session, harness, made.handle, { named: harness === 'claude', opened: true });
|
|
327
|
+
// Now the session has an address, and not before: on Claude Code, the name
|
|
328
|
+
// that line just gave it, which is the name another Claude session writes
|
|
329
|
+
// to. Its mailbox comes from the line's own first step, in the tab, and a
|
|
330
|
+
// mailbox Orca will not make there leaves a tab on the books all the same,
|
|
331
|
+
// which the next launch finishes (#317; review of PR #132, finding 3).
|
|
332
|
+
if (launch.address !== undefined) await writeAddress(home, session, launch.address);
|
|
332
333
|
|
|
333
334
|
// And then asking whether a TUI came up, rather than assuming one did. The
|
|
334
335
|
// text goes into the tab's own shell, which may have been busy with a
|
|
@@ -354,6 +355,9 @@ async function bringUpSession(bots, home, live, session, bot, title) {
|
|
|
354
355
|
bot: bot.name,
|
|
355
356
|
name: session.name,
|
|
356
357
|
created: true,
|
|
358
|
+
// Asked once the tab is up: the step at the head of the line has run by
|
|
359
|
+
// then, and whatever it said is on the tab's screen, not in this report.
|
|
360
|
+
noMailbox: lacksMailbox(home, session, harness),
|
|
357
361
|
...tui,
|
|
358
362
|
promptReceived,
|
|
359
363
|
promptFile: launch.promptFile,
|
|
@@ -365,23 +369,34 @@ async function bringUpSession(bots, home, live, session, bot, title) {
|
|
|
365
369
|
|
|
366
370
|
/**
|
|
367
371
|
* The line that starts this session: resuming `resume`, or, with none, a fresh
|
|
368
|
-
* start carrying its duty. Returns `{ resume, prompt, promptFile, command }`.
|
|
372
|
+
* start carrying its duty. Returns `{ resume, prompt, promptFile, command, address }`.
|
|
369
373
|
*/
|
|
370
|
-
function launchFor(bots, bot, session, harness, home, workDir, resume) {
|
|
374
|
+
function launchFor(bots, bot, session, harness, home, workDir, resume, held) {
|
|
371
375
|
const prompt = resume === undefined ? startPrompt(session, { home, workDir }) : undefined;
|
|
372
376
|
// Anything longer than a line goes to the harness out of a file, rather than
|
|
373
377
|
// through the tab's shell a character at a time.
|
|
374
378
|
const promptFile = prompt === undefined || isShortPrompt(prompt) ? undefined : promptPath(bots, bot.name, session.name);
|
|
375
|
-
|
|
379
|
+
// A new conversation is given a new name. A resume goes on under the one the
|
|
380
|
+
// kit gave it, and under no `-n` at all when the book `held` none of the
|
|
381
|
+
// kit's own: a resume keeps whatever name the conversation has, which is
|
|
382
|
+
// proven, and whether `-n` renames it is not (#286). Such a session is
|
|
383
|
+
// written to through its mailbox until it next starts fresh.
|
|
384
|
+
const address = harness !== 'claude' ? undefined
|
|
385
|
+
: resume === undefined ? addressOf(bot.name, session.name)
|
|
386
|
+
: isAddressOf(bot.name, session.name, held) ? held : undefined;
|
|
387
|
+
const line = launchCommand(session, {
|
|
376
388
|
harness,
|
|
377
389
|
home,
|
|
378
390
|
workDir,
|
|
379
391
|
prompt,
|
|
380
392
|
promptFile,
|
|
381
393
|
resume,
|
|
382
|
-
address
|
|
394
|
+
address,
|
|
383
395
|
});
|
|
384
|
-
|
|
396
|
+
// The session's mailbox first, from inside its own tab (#317), and the
|
|
397
|
+
// harness whatever became of it.
|
|
398
|
+
const command = reachesMail(session, harness) ? `${mailboxStep(bots, bot.name, session.name)}; ${line}` : line;
|
|
399
|
+
return { resume, prompt, promptFile, command, address };
|
|
385
400
|
}
|
|
386
401
|
|
|
387
402
|
/**
|
|
@@ -441,8 +456,8 @@ function lookFor(handle, timeoutMs) {
|
|
|
441
456
|
}
|
|
442
457
|
|
|
443
458
|
/**
|
|
444
|
-
*
|
|
445
|
-
*
|
|
459
|
+
* Write down the name a session answers to, where this run has just launched it
|
|
460
|
+
* under that name.
|
|
446
461
|
*
|
|
447
462
|
* The name is written by whoever typed the launch line that gave it, and by
|
|
448
463
|
* nobody else. A session that was already running when the kit reached it was
|
|
@@ -450,56 +465,143 @@ function lookFor(handle, timeoutMs) {
|
|
|
450
465
|
* that no harness answers to is worse than no name: the kit would send another
|
|
451
466
|
* session to an address with nobody at it.
|
|
452
467
|
*
|
|
468
|
+
* The session's mailbox is not made here. The launch line this run typed
|
|
469
|
+
* starts with `obk session mailbox`, which the new tab's shell runs before the
|
|
470
|
+
* harness: Orca 1.4.210 lets a process in a tab bind a Run to that tab and to
|
|
471
|
+
* no other, so the `obk` that opened the tab cannot (#317, `ownMailbox`).
|
|
472
|
+
*/
|
|
473
|
+
async function writeAddress(home, session, address) {
|
|
474
|
+
if (readBook(home).sessions[session.name]?.address === address) return;
|
|
475
|
+
|
|
476
|
+
await updateBook(home, (current) => {
|
|
477
|
+
current.sessions[session.name] = { ...current.sessions[session.name], address };
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* `obk session mailbox`: give one session its mailbox, bound to the tab this
|
|
483
|
+
* runs in, which is the session's own. The kit's launch line runs it in a new
|
|
484
|
+
* tab before the harness, and nothing else is meant to.
|
|
485
|
+
*
|
|
453
486
|
* The mailbox is an Orca Run, made once and kept for ever. It has to be a Run
|
|
454
487
|
* rather than the session's tab: Orca calls a terminal a live terminal-only
|
|
455
488
|
* mailbox, says plainly that delivery does not outlive the tab, and refuses a
|
|
456
489
|
* send once the pane is gone, while a Run survives a closed tab, a relaunch and
|
|
457
490
|
* a restart (tech notes, section 1). Orca offers no way to delete one, so this
|
|
458
|
-
* makes
|
|
491
|
+
* makes one for a session whose book names none, and otherwise binds the one
|
|
492
|
+
* the book names to this tab: after a restart, or a closed tab brought back,
|
|
493
|
+
* it would stay with the tab that is gone (review of PR #248, finding 1).
|
|
459
494
|
*
|
|
460
|
-
*
|
|
461
|
-
*
|
|
462
|
-
*
|
|
463
|
-
* session already had is bound again when this run `opened` a new tab for it —
|
|
464
|
-
* after a restart, or a closed tab brought back — or it would stay with the tab
|
|
465
|
-
* that is gone (review of PR #248, finding 1).
|
|
495
|
+
* Only inside an Orca tab. Asked from anywhere else, Orca picks a terminal
|
|
496
|
+
* itself, and seen live that was not the asker's (tech notes, section 1), so
|
|
497
|
+
* this refuses rather than bind somebody else's tab.
|
|
466
498
|
*
|
|
467
499
|
* A Codex session whose user turned the sandbox switch off gets none: it could
|
|
468
500
|
* not read a mailbox if it had one, and an address nobody can read is worse
|
|
469
501
|
* than none at all. `obk message` says so in those words.
|
|
470
502
|
*/
|
|
471
|
-
async function
|
|
472
|
-
const
|
|
473
|
-
|
|
503
|
+
export async function ownMailbox(bots, botName, sessionName) {
|
|
504
|
+
const home = botDir(bots, botName);
|
|
505
|
+
const bot = readBot(home, botName);
|
|
506
|
+
const session = (bot.sessions ?? []).find((entry) => entry.name === sessionName);
|
|
507
|
+
if (session === undefined) throw new Error(`${botName} has no session called ${sessionName} in ${path.join(home, 'bot.yaml')}.`);
|
|
508
|
+
const who = `${botName}/${sessionName}`;
|
|
509
|
+
|
|
510
|
+
if (!reachesMail(session, harnessOf(session, bot.harness))) {
|
|
511
|
+
return { bot: botName, session: sessionName, mailbox: null, change: 'none' };
|
|
512
|
+
}
|
|
513
|
+
if (process.env[TERMINAL_ENV] === undefined) {
|
|
514
|
+
throw new Error(`session mailbox binds ${who}'s mailbox to the Orca tab it runs in, and this is not one: ${TERMINAL_ENV} is not set. The kit runs it in the session's own tab when it starts the session.`);
|
|
515
|
+
}
|
|
474
516
|
|
|
475
|
-
|
|
476
|
-
|
|
517
|
+
// Only in the tab the book names for the session. Two runs of `up` at once
|
|
518
|
+
// can leave a session two tabs, and the book one of them: a mailbox made or
|
|
519
|
+
// bound in the other would be bound to a tab no message is meant for.
|
|
520
|
+
const entry = readBook(home).sessions[sessionName] ?? {};
|
|
521
|
+
const here = process.env[TAB_ENV];
|
|
522
|
+
if (entry.tab === undefined || entry.tab !== here) {
|
|
523
|
+
throw new Error(`${who}'s mailbox is given in the tab the book names for it, ${entry.tab ?? 'none yet'}, and this is ${here ?? 'a tab that does not say which it is'}. Nothing was made or bound.`);
|
|
524
|
+
}
|
|
477
525
|
|
|
478
|
-
|
|
526
|
+
const held = entry.mailbox;
|
|
527
|
+
if (typeof held === 'string') {
|
|
528
|
+
try {
|
|
529
|
+
useMailbox(held, undefined, STEP_WAIT);
|
|
530
|
+
} catch (error) {
|
|
531
|
+
// An Orca that did not answer may still have done it, and is not asked
|
|
532
|
+
// a second time.
|
|
533
|
+
if (error.code === TIMED_OUT) {
|
|
534
|
+
throw new Error(`${error.message}\n${who}'s mailbox ${held} may now be bound to this tab, or may still be bound where it was: Orca did not say which.`);
|
|
535
|
+
}
|
|
536
|
+
throw new Error(`${error.message}\n${who}'s mailbox ${held} is unchanged: it is still bound to ${boundTo(held)}.`);
|
|
537
|
+
}
|
|
538
|
+
return { bot: botName, session: sessionName, mailbox: held, change: 'bound' };
|
|
539
|
+
}
|
|
479
540
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
541
|
+
// Made outside the book's lock, which is held for one read and one write
|
|
542
|
+
// (book.js).
|
|
543
|
+
let made;
|
|
544
|
+
try {
|
|
545
|
+
made = makeMailbox(who, STEP_WAIT);
|
|
546
|
+
} catch (error) {
|
|
547
|
+
const left = error.code === TIMED_OUT
|
|
548
|
+
? `None is written down for ${who}; a Run Orca made after the kit stopped waiting is left unused.`
|
|
549
|
+
: `No mailbox was made for ${who}, and none is written down.`;
|
|
550
|
+
throw new Error(`${error.message}\n${left} It gets one the next time the kit starts it.`);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
let kept;
|
|
554
|
+
let movedTo;
|
|
555
|
+
try {
|
|
556
|
+
await updateBook(home, (current) => {
|
|
557
|
+
const now = { ...current.sessions[sessionName] };
|
|
558
|
+
// The book may have moved to another tab while Orca was asked: a second
|
|
559
|
+
// `up` of the same session. Then nothing is written here and nothing
|
|
560
|
+
// bound, so the tab the book names keeps whatever it has.
|
|
561
|
+
if (now.tab !== here) {
|
|
562
|
+
movedTo = now.tab ?? 'none';
|
|
563
|
+
return undefined;
|
|
564
|
+
}
|
|
565
|
+
// Under the lock, and only if the book still has none: two runs at once
|
|
566
|
+
// would each have made one, and a session with two mailboxes is a session
|
|
567
|
+
// half its mail never reaches. The loser's Run is left unused.
|
|
568
|
+
if (typeof now.mailbox !== 'string') now.mailbox = made;
|
|
569
|
+
kept = now.mailbox;
|
|
570
|
+
current.sessions[sessionName] = now;
|
|
571
|
+
return undefined;
|
|
572
|
+
});
|
|
573
|
+
} catch (error) {
|
|
574
|
+
throw new Error(`${error.message}\nOrca made the mailbox ${made} for ${who}, bound to this tab, but it could not be written into the book, so it is left unused. The next time the kit starts ${who}, it makes another.`);
|
|
575
|
+
}
|
|
576
|
+
if (movedTo !== undefined) {
|
|
577
|
+
throw new Error(`the book now names another tab for ${who}, ${movedTo}, so the mailbox ${made} made in this tab is left unused. It is bound to this tab, and nothing was written into the book: ${who}'s mailbox there is left where it is.`);
|
|
578
|
+
}
|
|
579
|
+
// One terminal holds one Run, so making the loser took this tab off the one
|
|
580
|
+
// the book kept. It goes back.
|
|
581
|
+
if (kept !== made) useMailbox(kept, undefined, STEP_WAIT);
|
|
582
|
+
return { bot: botName, session: sessionName, mailbox: kept, change: kept === made ? 'made' : 'bound' };
|
|
489
583
|
}
|
|
490
584
|
|
|
491
585
|
/**
|
|
492
|
-
*
|
|
493
|
-
*
|
|
494
|
-
*
|
|
495
|
-
*
|
|
496
|
-
* The Orca call is made outside the book's lock, which is held for one read and
|
|
497
|
-
* one write (book.js).
|
|
586
|
+
* How long the step gives each Orca call. The step runs in front of the
|
|
587
|
+
* harness on the launch line, so it has to end: an Orca that never answers
|
|
588
|
+
* would otherwise leave the tab blank and the session never started. Generous,
|
|
589
|
+
* because a busy machine is slow.
|
|
498
590
|
*/
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
591
|
+
const STEP_WAIT = { timeoutMs: 20_000 };
|
|
592
|
+
|
|
593
|
+
/** Whether a session that can have a mailbox has none in its book. */
|
|
594
|
+
const lacksMailbox = (home, session, harness) => reachesMail(session, harness)
|
|
595
|
+
&& typeof readBook(home).sessions[session.name]?.mailbox !== 'string';
|
|
596
|
+
|
|
597
|
+
/** Where a Run is bound, as a sentence can say it, or the plain truth when Orca will not say. */
|
|
598
|
+
function boundTo(id) {
|
|
599
|
+
try {
|
|
600
|
+
const handle = coordinatorOf(id, STEP_WAIT);
|
|
601
|
+
return handle === undefined ? 'no tab' : `the terminal ${handle}`;
|
|
602
|
+
} catch {
|
|
603
|
+
return 'whatever it was bound to before';
|
|
604
|
+
}
|
|
503
605
|
}
|
|
504
606
|
|
|
505
607
|
/**
|
|
@@ -568,7 +670,7 @@ const ids = (setup, change) => ({ project: setup.projectId, setup: setup.id, cha
|
|
|
568
670
|
export const promptPath = (bots, bot, session) =>
|
|
569
671
|
path.join(`${bots}.prompts`, `${encodeURIComponent(bot)}.${encodeURIComponent(session)}.txt`);
|
|
570
672
|
|
|
571
|
-
function entry(tab, { bot, name, created, running = false, blockedReason, promptReceived, promptFile, resumed, noConversation, unclaimed }) {
|
|
673
|
+
function entry(tab, { bot, name, created, running = false, blockedReason, promptReceived, promptFile, resumed, noConversation, unclaimed, noMailbox = false }) {
|
|
572
674
|
const made = { bot, name, title: tab.title, tabId: tab.tabId, terminal: tab.handle, created, harnessStarted: running };
|
|
573
675
|
// Whether this run picked the session up where it was or started a new one.
|
|
574
676
|
// Only for a tab this run opened: a tab that was already there was left alone.
|
|
@@ -578,6 +680,9 @@ function entry(tab, { bot, name, created, running = false, blockedReason, prompt
|
|
|
578
680
|
// Orca's own words for what is on screen waiting to be answered, when it
|
|
579
681
|
// gave any: the caller acts on it, the kit only passes it on.
|
|
580
682
|
if (blockedReason !== undefined) made.blockedReason = blockedReason;
|
|
683
|
+
// A session that should have a mailbox and has none: the step in its tab
|
|
684
|
+
// did not make one, and nothing in this run would (#317).
|
|
685
|
+
if (noMailbox) made.noMailbox = true;
|
|
581
686
|
// Conversations the harness still has in this bot's folder that no session
|
|
582
687
|
// claims: the caller is told, because the kit will not pick one.
|
|
583
688
|
if (unclaimed !== undefined && unclaimed.length > 0) made.unclaimed = unclaimed;
|