@assuredloop/orca-bot-kit 0.25.2 → 0.25.4
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 +17 -6
- package/SETUP.md +1 -1
- package/package.json +1 -1
- package/src/clear.js +42 -25
- package/src/cli.js +59 -9
- package/src/conversations.js +37 -0
- package/src/health.js +17 -2
- package/src/hooks.js +25 -1
- package/src/launch.js +2 -2
- package/src/message.js +219 -42
- package/src/name.js +4 -3
- package/src/orca.js +141 -16
- package/src/retire.js +31 -6
- package/src/unread.js +160 -0
- package/src/up.js +32 -11
package/README.md
CHANGED
|
@@ -350,6 +350,11 @@ address that is none of your bots folder's sessions, the hook tells it so, and
|
|
|
350
350
|
names the address `obk message to` would have given. The message goes either
|
|
351
351
|
way ([ADR 0032](docs/adr/0032-a-native-message-outside-the-fleet-is-warned-about.md)).
|
|
352
352
|
|
|
353
|
+
And a third: at the end of each turn, it tells the session once about fleet
|
|
354
|
+
mail the kit sent it that is still unread, without typing into its tab
|
|
355
|
+
([ADR 0035](docs/adr/0035-messaging-transport.md)). It asks Orca only when the kit
|
|
356
|
+
knows of mail for that session.
|
|
357
|
+
|
|
353
358
|
A Codex bot gets one more hook too. Inside Codex's sandbox the kit cannot always
|
|
354
359
|
tell whether the receiver of a message has its harness in front, as for a Claude
|
|
355
360
|
session busy running a command, so `obk message send` leaves that nudge, and the
|
|
@@ -555,7 +560,7 @@ verdict: which of these matter, and in what order, is for you or for Bot Father.
|
|
|
555
560
|
## Sessions and bots that talk
|
|
556
561
|
|
|
557
562
|
Ask the kit how to reach a session, and it answers with the road and the
|
|
558
|
-
address ([ADR
|
|
563
|
+
address ([ADR 0035](docs/adr/0035-messaging-transport.md)):
|
|
559
564
|
|
|
560
565
|
```sh
|
|
561
566
|
obk message to --bots /path/to/my-bots --to api-bot/daily
|
|
@@ -598,11 +603,17 @@ mailbox where it was and says so, and the harness starts all the same. A check
|
|
|
598
603
|
holds its turn for 40 seconds at most: with more mail than it reads in that
|
|
599
604
|
time, it shows what it read and says the rest is still waiting.
|
|
600
605
|
|
|
601
|
-
Nothing in a mailbox wakes anybody, so
|
|
602
|
-
the receiver
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
+
Nothing in a mailbox wakes anybody, so each message gets one signal that tells
|
|
607
|
+
the receiver to look. Orca's own notice comes first: Orca types it into an idle
|
|
608
|
+
tab. The kit watches an idle receiver for up to 8 seconds, and reads the
|
|
609
|
+
receiver's own record of its turns. When Orca's notice is there, the kit types
|
|
610
|
+
nothing. When no turn started, the kit types its own line. A Claude session busy
|
|
611
|
+
with a turn gets nothing typed: its hook tells it at the end of the turn. A busy
|
|
612
|
+
Codex session gets the kit's line, which Codex takes into the turn it is having.
|
|
613
|
+
The send says which signal went. A session that is not up is not typed into at
|
|
614
|
+
all; its message waits. When a session is retired, the kit says how many
|
|
615
|
+
messages sent to it were not read with `obk message check`, and who sent them
|
|
616
|
+
([ADR 0035](docs/adr/0035-messaging-transport.md)).
|
|
606
617
|
|
|
607
618
|
A message longer than 4 KiB is written to a file beside your bots folder and
|
|
608
619
|
named in the message, so a long one arrives whole without landing a document in
|
package/SETUP.md
CHANGED
|
@@ -201,7 +201,7 @@ waiting for:
|
|
|
201
201
|
| Claude Code's folder trust list | `\x1b[B\r` | down, then return: its selection starts on **No, exit** | Claude Code 2.1.283 |
|
|
202
202
|
| Codex's directory trust, `1. Trust and continue` (older: `1. Yes, continue`) | `\r` | return: its selection starts on **Trust and continue** | Codex 0.160.0 |
|
|
203
203
|
| Codex's `Hooks need review` | `\x1b[B\r` | down, then return, to **Trust all and continue**; if the selection already starts there, return alone | Codex 0.160.0 |
|
|
204
|
-
| Codex's `/new`: `Where should the new conversation run?` | `\r` | return: its selection starts on **Current checkout** (bot home) | Codex 0.160.0 |
|
|
204
|
+
| 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) |
|
|
205
205
|
| 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 |
|
|
206
206
|
| `[oh-my-zsh] Would you like to update?` | `n` | they update their own shell | oh-my-zsh bf77e35 |
|
|
207
207
|
| Claude Code's `Teach auto mode about your environment?` | `\x1b` | Esc, which cancels it (**Not now**). On 2.1.283 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`. **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 |
|
package/package.json
CHANGED
package/src/clear.js
CHANGED
|
@@ -38,9 +38,13 @@ const POINTER = { claude: '❯', codex: '›' };
|
|
|
38
38
|
*/
|
|
39
39
|
const RECORD_LINE = 'obk: this conversation was started by obk session clear, and this line is only so the kit can record it. Reply "ok"; nothing else is asked.';
|
|
40
40
|
|
|
41
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* What Codex asks after `/new`, and the one answer the kit gives it: the bot
|
|
43
|
+
* home it runs in. Codex 0.162.0 calls it by another name, and its whole row
|
|
44
|
+
* has to be as captured, but for the padding before its description (#516).
|
|
45
|
+
*/
|
|
42
46
|
const WHERE_TO_RUN = 'Where should the new conversation run?';
|
|
43
|
-
const CURRENT_CHECKOUT = /^ *› +1\. Current checkout\b
|
|
47
|
+
const CURRENT_CHECKOUT = /^ *› +1\. Current checkout\b|^ *› 1\. Use current Git worktree\s+Keep using the current working directory *$/;
|
|
44
48
|
|
|
45
49
|
/** Codex's empty input line: its pointer alone, or with its placeholder. */
|
|
46
50
|
const CODEX_EMPTY = ['›', '› Ask Codex to do anything'];
|
|
@@ -51,7 +55,8 @@ const CODEX_EMPTY = ['›', '› Ask Codex to do anything'];
|
|
|
51
55
|
* selected row stands for what Return will run: with the menu open, Return
|
|
52
56
|
* runs the selected command, and the menu is narrowed to what was typed
|
|
53
57
|
* (codex-rs/tui/src/bottom_pane/chat_composer.rs, rust-v0.160.0). The
|
|
54
|
-
* architect's ruling on #391, after live run 4.
|
|
58
|
+
* architect's ruling on #391, after live run 4. Only where Orca gives no
|
|
59
|
+
* draft: a draft is the line's text, and has to be the command (#516).
|
|
55
60
|
*/
|
|
56
61
|
const CODEX_BARE_LINE = '0.160.0';
|
|
57
62
|
|
|
@@ -221,16 +226,19 @@ async function enter(it, verb, before = () => {}) {
|
|
|
221
226
|
* when it was typed.
|
|
222
227
|
*/
|
|
223
228
|
export async function typeCommand(it, command, { before = () => {}, check = menuWrong, cannot = () => '', deadline = Infinity } = {}) {
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
|
|
229
|
+
// Nothing may be in the input line before: no draft for the command to join.
|
|
230
|
+
// Orca gives a draft in the line beside the screen, and the screen's line
|
|
231
|
+
// then reads the pointer alone (#510, #516). Where it gives none, Codex's
|
|
232
|
+
// line on the screen has to be empty.
|
|
233
|
+
const emptyLine = ({ rows, draft }) => {
|
|
234
|
+
if (draft !== undefined) throw new Error(`${it.name}: nothing was typed, because its input line holds a draft: it reads "${draft}".${shownEnd(rows)}`);
|
|
227
235
|
if (it.harness !== 'codex') return;
|
|
228
236
|
const line = rows.findLast((row) => /^ *›/.test(row))?.trim();
|
|
229
237
|
if (!CODEX_EMPTY.includes(line)) {
|
|
230
238
|
throw new Error(`${it.name}: nothing was typed, because its input line is not empty: it reads "${line ?? 'nothing'}".${shownEnd(rows)}`);
|
|
231
239
|
}
|
|
232
240
|
};
|
|
233
|
-
emptyLine(
|
|
241
|
+
emptyLine(await idleTab(it));
|
|
234
242
|
const version = it.harness === 'codex' ? codexVersion(readBook(it.home).sessions[it.session]?.session) : undefined;
|
|
235
243
|
|
|
236
244
|
before();
|
|
@@ -238,7 +246,7 @@ export async function typeCommand(it, command, { before = () => {}, check = menu
|
|
|
238
246
|
// with no wait, before the first key (#480 review).
|
|
239
247
|
const ready = lookAt(it);
|
|
240
248
|
if (ready.why !== undefined) throw new Error(`${it.name}: nothing was typed, because ${ready.why}.`);
|
|
241
|
-
emptyLine(ready
|
|
249
|
+
emptyLine(ready);
|
|
242
250
|
const { handle } = ready;
|
|
243
251
|
const typedAt = Date.now();
|
|
244
252
|
// One character a send, each after a look through the gate, so nothing goes
|
|
@@ -329,7 +337,7 @@ async function idleTab(it) {
|
|
|
329
337
|
}
|
|
330
338
|
|
|
331
339
|
/**
|
|
332
|
-
* One look through the typing gate at the session's tab: `{ handle }` when
|
|
340
|
+
* One look through the typing gate at the session's tab: `{ handle, rows, draft }` when
|
|
333
341
|
* nothing on its screen asks a question and nothing says it is at work, and,
|
|
334
342
|
* with `idle`, Orca's tui-idle answered ok; or `{ why }`, naming the signal,
|
|
335
343
|
* with `moved` when the book holds another tab for the session now, or, where
|
|
@@ -364,7 +372,7 @@ function lookAt(it, { idle = true } = {}) {
|
|
|
364
372
|
const signal = signalIn(found.rows);
|
|
365
373
|
if (signal !== undefined) return { why: signal };
|
|
366
374
|
if (idle && !found.idle) return { why: 'it is busy with a turn: Orca\'s tui-idle did not answer ok' };
|
|
367
|
-
return { handle: found.handle, rows: found.rows };
|
|
375
|
+
return { handle: found.handle, rows: found.rows, draft: found.draft };
|
|
368
376
|
}
|
|
369
377
|
|
|
370
378
|
/**
|
|
@@ -384,7 +392,7 @@ async function typedWrong(handle, harness, command, version, check) {
|
|
|
384
392
|
// a question that shows on it stops it at once (review of c1e5ba4).
|
|
385
393
|
const signal = seen.rows === undefined ? undefined : signalIn(seen.rows);
|
|
386
394
|
if (signal !== undefined) return { why: signal, rows: seen.rows };
|
|
387
|
-
const wrong = seen.rows === undefined ? { why: `its screen could not be read (${seen.unreadable})` } : check(seen.rows, harness, command, version);
|
|
395
|
+
const wrong = seen.rows === undefined ? { why: `its screen could not be read (${seen.unreadable})` } : check(seen.rows, harness, command, version, seen.draft);
|
|
388
396
|
if (wrong === undefined || Date.now() >= until) return wrong && { ...wrong, rows: seen.rows };
|
|
389
397
|
await pause(ASK_MS);
|
|
390
398
|
}
|
|
@@ -406,13 +414,17 @@ function signalIn(rows) {
|
|
|
406
414
|
return undefined;
|
|
407
415
|
}
|
|
408
416
|
|
|
409
|
-
function menuWrong(rows, harness, command, version) {
|
|
417
|
+
function menuWrong(rows, harness, command, version, draft) {
|
|
410
418
|
const at = rows.findLastIndex((row) => /^ *[›❯]/.test(row));
|
|
411
419
|
if (at < 0) return { why: 'its screen shows no input line' };
|
|
412
420
|
// Claude Code 2.1.288 puts a non-breaking space after its pointer (live run 4).
|
|
413
421
|
const line = rows[at].replaceAll('\u00a0', ' ').trim();
|
|
414
|
-
|
|
415
|
-
|
|
422
|
+
// Orca 1.4.223 gives the line's text as the draft, and the line on the
|
|
423
|
+
// screen then reads the pointer alone: Claude Code 2.1.296's (#510, probe 4)
|
|
424
|
+
// and Codex 0.162.0's (#516).
|
|
425
|
+
const text = draft === undefined ? line : `${POINTER[harness]} ${draft}`;
|
|
426
|
+
if (harness === 'codex') return codexMenuWrong(rows, at, text, command, version);
|
|
427
|
+
if (text !== `${POINTER[harness]} ${command}`) return { why: `its input line reads "${text}"` };
|
|
416
428
|
return claudeMenuWrong(rows, at, command);
|
|
417
429
|
}
|
|
418
430
|
|
|
@@ -420,25 +432,30 @@ function menuWrong(rows, harness, command, version) {
|
|
|
420
432
|
* Claude Code 2.1.288 draws its slash menu above its input box's top rule, a
|
|
421
433
|
* row of ─: a row per command, starting with it, its description wrapped onto
|
|
422
434
|
* rows set far in (live run 4). No pointer marks a selection, so the first
|
|
423
|
-
* command row there has to be the command.
|
|
435
|
+
* command row there has to be the command. Claude Code 2.1.296 starts the
|
|
436
|
+
* selected row with its pointer, ` ❯ /compact`, and that row has to be the
|
|
437
|
+
* command (#510, probes 1 and 2). The menu's pointer is above the rule, the
|
|
438
|
+
* input line's below it, so the one is never taken for the other.
|
|
424
439
|
*/
|
|
425
440
|
function claudeMenuWrong(rows, at, command) {
|
|
426
441
|
const rule = rows.slice(0, at).findLastIndex((row) => /^\s*─{3}/.test(row));
|
|
427
442
|
const menu = [];
|
|
428
|
-
for (let up = rule - 1; up >= 0 && (/^ {1,4}
|
|
429
|
-
const
|
|
430
|
-
|
|
431
|
-
if (
|
|
443
|
+
for (let up = rule - 1; up >= 0 && (/^ {1,4}(?:❯ +)?\/\S/.test(rows[up]) || /^ {20,}\S/.test(rows[up])); up -= 1) menu.unshift(rows[up]);
|
|
444
|
+
const pointed = menu.find((row) => /^ {1,4}❯ +\//.test(row));
|
|
445
|
+
const selected = pointed === undefined ? menu.find((row) => /^ {1,4}\//.test(row)) : pointed.replace(/^ *❯ +/, '');
|
|
446
|
+
if (rule < 0 || selected === undefined) return { why: 'no menu of commands came up above its input box', menu: true };
|
|
447
|
+
if (selected.trim().split(/\s+/)[0] !== command) return { why: `the ${pointed === undefined ? 'first' : 'selected'} row of its menu is "${selected.trim()}"`, menu: true };
|
|
432
448
|
return undefined;
|
|
433
449
|
}
|
|
434
450
|
|
|
435
451
|
/**
|
|
436
452
|
* Codex draws its slash menu above the input line: a row per command, the
|
|
437
|
-
* selected one starting with its pointer, then a blank row
|
|
438
|
-
* line (its own snapshot tests, chat_composer
|
|
439
|
-
* rust-v0.160.0; live run 4). The menu has
|
|
440
|
-
* selected one, naming the command. The input
|
|
441
|
-
*
|
|
453
|
+
* selected one starting with its pointer, then a blank row on 0.160.0 and none
|
|
454
|
+
* on 0.162.0, then the input line (its own snapshot tests, chat_composer
|
|
455
|
+
* slash_popup_*.snap, rust-v0.160.0; live run 4; #516's probe). The menu has
|
|
456
|
+
* to hold one command row, the selected one, naming the command. The input
|
|
457
|
+
* line, Orca's draft where it gives one, reads the command, or, with no draft
|
|
458
|
+
* on the Codex whose line Orca shows bare, its pointer alone.
|
|
442
459
|
*/
|
|
443
460
|
function codexMenuWrong(rows, at, line, command, version) {
|
|
444
461
|
const bare = line === '›' && version === CODEX_BARE_LINE;
|
|
@@ -483,7 +500,7 @@ async function answerWhereToRun(it, handle, command) {
|
|
|
483
500
|
const pointer = rows.findLast((row) => /^ *›/.test(row));
|
|
484
501
|
if (pointer === undefined || !CURRENT_CHECKOUT.test(pointer)) {
|
|
485
502
|
send(handle, '\x1b');
|
|
486
|
-
throw new Error(`${it.name}: after ${command}, Codex asked "${WHERE_TO_RUN}" with its selection on "${pointer?.trim() ?? 'nothing'}" rather than "1. Current checkout", so it was backed out of with Esc and nothing else was typed. Look at its tab.`);
|
|
503
|
+
throw new Error(`${it.name}: after ${command}, Codex asked "${WHERE_TO_RUN}" with its selection on "${pointer?.trim() ?? 'nothing'}" rather than "1. Current checkout" (on Codex 0.162.0, "1. Use current Git worktree"), so it was backed out of with Esc and nothing else was typed. Look at its tab.`);
|
|
487
504
|
}
|
|
488
505
|
send(handle, '\r');
|
|
489
506
|
return;
|
package/src/cli.js
CHANGED
|
@@ -18,14 +18,14 @@ import { checkHealth, orcaSettingFindings } from './health.js';
|
|
|
18
18
|
import { initBots } from './init.js';
|
|
19
19
|
import { nameSession } from './name.js';
|
|
20
20
|
import { APPROVALS, HARNESSES, ownCli, shellWord, workDirOf } from './launch.js';
|
|
21
|
-
import { checkMail, decideLeftNudges, lookUp, noMailboxYet, sendMessage, sessionInTab } from './message.js';
|
|
21
|
+
import { checkMail, decideLeftNudges, lookUp, noMailboxYet, sendMessage, sessionInTab, stillUnread, WATCH_MS } from './message.js';
|
|
22
22
|
import { orcaCli, orcaTrouble, RELOAD_LINE, TERMINAL_ENV } from './orca.js';
|
|
23
23
|
import { allowCommand, allowIn, beyondDefaults, refuseBroad, refuseNoCodexForm, runsOnClaude, runsOnCodex, takeBack, writePermissions } from './permissions.js';
|
|
24
24
|
import { pauseSessions, unpauseSessions } from './pause.js';
|
|
25
25
|
import { recordSession, SHELL_ENV, TAB_ENV } from './record.js';
|
|
26
26
|
import { restartSessions } from './restart.js';
|
|
27
27
|
import { clearSession, compactSession } from './clear.js';
|
|
28
|
-
import { retireBot, retireSession } from './retire.js';
|
|
28
|
+
import { retireBot, retireSession, unreadWords } from './retire.js';
|
|
29
29
|
import { sentWarning } from './sent.js';
|
|
30
30
|
import { readRoster } from './roster.js';
|
|
31
31
|
import { buildAgents, buildRules, CODEX_CAP } from './rules.js';
|
|
@@ -281,6 +281,11 @@ Usage:
|
|
|
281
281
|
nudges a send there left because it could not
|
|
282
282
|
tell from inside Codex's sandbox, and types each
|
|
283
283
|
one or says why not.
|
|
284
|
+
obk session mail --bots <path> --bot <bot>
|
|
285
|
+
For the kit's own hook, not for typing: at the end
|
|
286
|
+
of a Claude session's turn, it tells the session
|
|
287
|
+
once about each fleet mail the kit sent it that is
|
|
288
|
+
still unread. It types nothing into the tab.
|
|
284
289
|
obk session name --bots <path> --bot <bot>
|
|
285
290
|
For the kit's own hook, not for typing: after a
|
|
286
291
|
Codex session's turn, it names the session's thread
|
|
@@ -329,6 +334,7 @@ const COMMANDS = {
|
|
|
329
334
|
'session record': ['bots', 'bot'],
|
|
330
335
|
'session sent': ['bots', 'bot'],
|
|
331
336
|
'session nudge': ['bots', 'bot'],
|
|
337
|
+
'session mail': ['bots', 'bot'],
|
|
332
338
|
'session name': ['bots', 'bot'],
|
|
333
339
|
'session mailbox': ['bots', 'bot', 'session'],
|
|
334
340
|
'temp make': ['bots', 'name'],
|
|
@@ -463,6 +469,7 @@ async function run(argv) {
|
|
|
463
469
|
// there included, so it goes before anything that can complain (ADR 0032).
|
|
464
470
|
if (command === SENT) return sent(path.resolve(values.bots));
|
|
465
471
|
if (command === NUDGE) return nudgeLeft();
|
|
472
|
+
if (command === MAIL) return mailHook(path.resolve(values.bots), values.bot);
|
|
466
473
|
if (command === NAME) return nameThread(path.resolve(values.bots), values.bot);
|
|
467
474
|
|
|
468
475
|
// One fleet, one identity, whatever spelling of its path was given (#164).
|
|
@@ -534,6 +541,7 @@ const ANSWER_IT = [
|
|
|
534
541
|
const RECORD = 'session record';
|
|
535
542
|
const SENT = 'session sent';
|
|
536
543
|
const NUDGE = 'session nudge';
|
|
544
|
+
const MAIL = 'session mail';
|
|
537
545
|
const NAME = 'session name';
|
|
538
546
|
|
|
539
547
|
/**
|
|
@@ -559,11 +567,11 @@ function sent(bots) {
|
|
|
559
567
|
* what became of each, as Codex's `additionalContext` (#350, ADR 0034). Silent
|
|
560
568
|
* when nothing was left, never a decision, and never anything but exit 0.
|
|
561
569
|
*/
|
|
562
|
-
function nudgeLeft() {
|
|
570
|
+
async function nudgeLeft() {
|
|
563
571
|
try {
|
|
564
572
|
const said = JSON.parse(readFileSync(0, 'utf8'));
|
|
565
573
|
if (said?.hook_event_name !== 'PostToolUse') return 0;
|
|
566
|
-
const decided = decideLeftNudges(process.env[TAB_ENV]);
|
|
574
|
+
const decided = await decideLeftNudges(process.env[TAB_ENV]);
|
|
567
575
|
if (decided.length > 0) {
|
|
568
576
|
const words = decided.map((one) => `Your mail "${one.subject}" to ${one.to}: ${one.nudged ? "the kit's hook nudged its tab, from outside Codex's sandbox:" : "the kit's hook looked at its tab from outside Codex's sandbox, and"} ${nudgeLine(one, one.to).trim()}`);
|
|
569
577
|
process.stdout.write(`${JSON.stringify({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: words.join('\n') } })}\n`);
|
|
@@ -574,6 +582,24 @@ function nudgeLeft() {
|
|
|
574
582
|
return 0;
|
|
575
583
|
}
|
|
576
584
|
|
|
585
|
+
/**
|
|
586
|
+
* What the kit's Claude Code Stop hook does at a turn end (#509, ADR 0035): tell
|
|
587
|
+
* the session once about fleet mail still unread, as the Stop event's
|
|
588
|
+
* additional context, which the session goes on with. Not a block: Claude Code
|
|
589
|
+
* 2.1.296 draws a block's reason in red as "Stop hook error", and its additional
|
|
590
|
+
* context as "Stop hook feedback". Nothing typed into the tab; never anything
|
|
591
|
+
* but exit 0; silent whenever it cannot be sure.
|
|
592
|
+
*/
|
|
593
|
+
function mailHook(bots, bot) {
|
|
594
|
+
try {
|
|
595
|
+
const reason = stillUnread(sameFleet(bots), bot, JSON.parse(readFileSync(0, 'utf8')), process.env[TAB_ENV], process.env[TERMINAL_ENV]);
|
|
596
|
+
if (reason !== undefined) process.stdout.write(`${JSON.stringify({ hookSpecificOutput: { hookEventName: 'Stop', additionalContext: reason } })}\n`);
|
|
597
|
+
} catch {
|
|
598
|
+
// Nothing: a hook does not disturb the session it runs in.
|
|
599
|
+
}
|
|
600
|
+
return 0;
|
|
601
|
+
}
|
|
602
|
+
|
|
577
603
|
/**
|
|
578
604
|
* What the kit's Codex Stop hook does at a turn end: name the session's thread
|
|
579
605
|
* after its bot and session, once it is idle (#480). Silent, never a decision,
|
|
@@ -703,6 +729,7 @@ const commands = {
|
|
|
703
729
|
...withLines(retired.retiredWith, retired.session),
|
|
704
730
|
...closedLines(retired.closed),
|
|
705
731
|
`retired ${retired.bot} ${retired.session}: off ${path.join('bots', retired.bot, 'bot.yaml')}, and its conversations kept in the book under retired`,
|
|
732
|
+
...unreadLines(retired.unread),
|
|
706
733
|
...leftLines(retired.promptsLeft),
|
|
707
734
|
],
|
|
708
735
|
};
|
|
@@ -732,6 +759,7 @@ const commands = {
|
|
|
732
759
|
retired.windowReloaded ? "reloaded Orca's window, so its sidebar no longer shows the project" : RELOAD_LINE,
|
|
733
760
|
]),
|
|
734
761
|
`retired ${retired.bot}: moved to ${path.relative(bots, retired.moved)}, with its book, charter and memory`,
|
|
762
|
+
...(retired.unread ?? []).flatMap((one) => unreadLines(one, `${retired.bot}/${one.session}`)),
|
|
735
763
|
...leftLines(retired.promptsLeft),
|
|
736
764
|
],
|
|
737
765
|
};
|
|
@@ -788,6 +816,7 @@ const commands = {
|
|
|
788
816
|
...withLines(retired.retiredWith, retired.session),
|
|
789
817
|
...closedLines(retired.closed),
|
|
790
818
|
`retired ${retired.bot} ${retired.session}, a temporary session of ${retired.maker}'s: off ${path.join('bots', retired.bot, 'bot.yaml')}, and its conversations kept in the book under retired`,
|
|
819
|
+
...unreadLines(retired.unread),
|
|
791
820
|
...leftLines(retired.promptsLeft),
|
|
792
821
|
],
|
|
793
822
|
};
|
|
@@ -1081,10 +1110,10 @@ const commands = {
|
|
|
1081
1110
|
};
|
|
1082
1111
|
},
|
|
1083
1112
|
|
|
1084
|
-
'message send'(bots, values) {
|
|
1113
|
+
async 'message send'(bots, values) {
|
|
1085
1114
|
const tab = callerTab(bots, values.from === undefined);
|
|
1086
1115
|
refuseWhenOrcaIsDown();
|
|
1087
|
-
const answer = sendMessage(bots, {
|
|
1116
|
+
const answer = await sendMessage(bots, {
|
|
1088
1117
|
to: values.to,
|
|
1089
1118
|
from: values.from,
|
|
1090
1119
|
tab,
|
|
@@ -1174,6 +1203,7 @@ const commands = {
|
|
|
1174
1203
|
const said = {
|
|
1175
1204
|
made: `${who} has its mailbox ${answer.mailbox}, made in this tab.`,
|
|
1176
1205
|
bound: `${who}'s mailbox ${answer.mailbox} is bound to this tab.`,
|
|
1206
|
+
replaced: `${who}'s mailbox ${answer.replaced} is not one this Orca has, as when the book was written on another machine, so it has a new one, ${answer.mailbox}, made in this tab and written into the book.`,
|
|
1177
1207
|
none: `${who} gets no mailbox: it is a Codex session with its sandbox switch off, which could not read one.`,
|
|
1178
1208
|
};
|
|
1179
1209
|
return { answer, lines: [said[answer.change]] };
|
|
@@ -1248,13 +1278,26 @@ function toLines(answer, bots, where) {
|
|
|
1248
1278
|
* about the message.
|
|
1249
1279
|
*/
|
|
1250
1280
|
function nudgeLine(answer, where) {
|
|
1281
|
+
const seconds = answer.watchedMs === undefined ? '' : ` in ${Math.max(Math.round(answer.watchedMs / 1000), 1)} s`;
|
|
1282
|
+
if (answer.signal === 'orca') return ` Orca's notice reached it${seconds}, so no line was typed: it reads the mail from there.`;
|
|
1283
|
+
if (answer.signal === 'hook') {
|
|
1284
|
+
return answer.because === 'busy'
|
|
1285
|
+
? " it is busy with a turn, so nothing was typed into its tab: its own hook tells it about the mail when that turn ends."
|
|
1286
|
+
: ` a turn of other work started in its tab${seconds}, not Orca's notice, so nothing was typed: its own hook tells it about the mail when that turn ends.`;
|
|
1287
|
+
}
|
|
1288
|
+
const why = {
|
|
1289
|
+
'no-turn': `no turn started in its tab in ${WATCH_SECONDS} s, so the kit's line was typed`,
|
|
1290
|
+
busy: "it is busy with a turn, and Codex takes a line into the turn it is having, so the kit's line was typed",
|
|
1291
|
+
'other-turn': "a turn of other work started in its tab, not Orca's notice, so the kit's line was typed",
|
|
1292
|
+
}[answer.because];
|
|
1293
|
+
const head = why === undefined ? ' ' : ` ${why}: `;
|
|
1251
1294
|
if (answer.nudged && answer.nudgeWatched === false) {
|
|
1252
|
-
return
|
|
1295
|
+
return `${head}its tab was typed into, and Orca could not say whether it was taken: ${answer.nudgeUnseen} The message waits in its mailbox for its next check either way.`;
|
|
1253
1296
|
}
|
|
1254
1297
|
if (answer.nudged && answer.nudgeUnseen !== undefined) {
|
|
1255
|
-
return
|
|
1298
|
+
return `${head}its tab was typed into, but Orca did not see the line start a turn: it may be queued behind the work in hand, or lost. The message waits in its mailbox for its next check either way.`;
|
|
1256
1299
|
}
|
|
1257
|
-
if (answer.nudged) return
|
|
1300
|
+
if (answer.nudged) return `${head}its tab was told to look; it will read it when it is done with what it is doing.`;
|
|
1258
1301
|
if (answer.blocked !== undefined) {
|
|
1259
1302
|
return ` its tab has something waiting to be answered (${answer.blocked}), so nothing was typed into it. Settle that, and the mail is there.`;
|
|
1260
1303
|
}
|
|
@@ -1267,6 +1310,9 @@ function nudgeLine(answer, where) {
|
|
|
1267
1310
|
return ` ${where} is not up, so nothing was typed anywhere: the message waits in its mailbox.`;
|
|
1268
1311
|
}
|
|
1269
1312
|
|
|
1313
|
+
/** The watch before the kit's line, as the send says it (#509, ADR 0035). */
|
|
1314
|
+
const WATCH_SECONDS = WATCH_MS / 1000;
|
|
1315
|
+
|
|
1270
1316
|
/**
|
|
1271
1317
|
* What there is to say about the work dir this command was given: one finding
|
|
1272
1318
|
* when it leads out of the bot home, through `..` or a link or as a path
|
|
@@ -1301,9 +1347,13 @@ const closedLines = (closed) => closed.map((tab) => `closed ${tab.bot} ${tab
|
|
|
1301
1347
|
const withLines = (retiredWith, session) => retiredWith.flatMap((gone) => [
|
|
1302
1348
|
...closedLines(gone.closed),
|
|
1303
1349
|
`retired ${gone.bot} ${gone.session}, a temporary session of ${gone.maker}'s, along with ${session}: off ${path.join('bots', gone.bot, 'bot.yaml')}, and its conversations kept in the book under retired`,
|
|
1350
|
+
...unreadLines(gone.unread),
|
|
1304
1351
|
...leftLines(gone.promptsLeft),
|
|
1305
1352
|
]);
|
|
1306
1353
|
|
|
1354
|
+
/** The mail a retired session did not read, as far as the kit knows (#509). */
|
|
1355
|
+
const unreadLines = (unread, who = 'it') => (unread === undefined ? [] : [`unread ${unreadWords(unread, who)}`]);
|
|
1356
|
+
|
|
1307
1357
|
/** What a retire could not remove, and how to: nothing reads these files now (#393). */
|
|
1308
1358
|
const leftLines = (left = []) => left.flatMap(({ file, reason }) => [
|
|
1309
1359
|
`left ${file}: it could not be removed (${reason}). Nothing reads it now.`,
|
package/src/conversations.js
CHANGED
|
@@ -159,6 +159,43 @@ export function heldAsUserTurn(harness, file, text) {
|
|
|
159
159
|
return held;
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
/**
|
|
163
|
+
* Where the harness keeps the conversation `id` of this bot home, and how long
|
|
164
|
+
* that record is now: `{ harness, file, size }`, so a later look reads only
|
|
165
|
+
* what was written after this moment. Undefined when there is no id, no
|
|
166
|
+
* record, or it cannot be read (#509).
|
|
167
|
+
*/
|
|
168
|
+
export function recordMark(harness, home, id) {
|
|
169
|
+
if (typeof id !== 'string' || id === '') return undefined;
|
|
170
|
+
try {
|
|
171
|
+
const file = harness === 'codex' ? codexRollout(id) : claudeTranscript(home, id);
|
|
172
|
+
if (file === undefined) return undefined;
|
|
173
|
+
const found = statSync(file);
|
|
174
|
+
return found.isFile() ? { harness, file, size: found.size } : undefined;
|
|
175
|
+
} catch {
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Whether a turn of the user's written after `mark` holds `text` anywhere in
|
|
182
|
+
* it (#509). A record that cannot be read says no: not seeing a thing is not
|
|
183
|
+
* seeing it.
|
|
184
|
+
*/
|
|
185
|
+
export function userTurnSince(mark, text) {
|
|
186
|
+
if (mark === undefined || mark === null) return false;
|
|
187
|
+
let held = false;
|
|
188
|
+
try {
|
|
189
|
+
eachLine(mark.file, (line) => {
|
|
190
|
+
held = line !== undefined && userTexts(mark.harness, line).some((said) => said.includes(text));
|
|
191
|
+
return held;
|
|
192
|
+
}, { from: mark.size });
|
|
193
|
+
} catch {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
return held;
|
|
197
|
+
}
|
|
198
|
+
|
|
162
199
|
/** The texts of one record line, when it is a turn of the user's. */
|
|
163
200
|
function userTexts(harness, line) {
|
|
164
201
|
let parsed;
|
package/src/health.js
CHANGED
|
@@ -26,7 +26,7 @@ import { botDir, botNames, botsDir, readBot, tempRoles, unknownKeys } from './bo
|
|
|
26
26
|
import { transcriptsIn } from './conversations.js';
|
|
27
27
|
import { hookTrouble } from './hooks.js';
|
|
28
28
|
import { bypassFlags, harnessOf, HARNESSES, isAddressOf, ownCli, sessionTrouble, SHELL_ENV, shellWord } from './launch.js';
|
|
29
|
-
import { frontOfTab, orcaDefaultArgs, projects, tabs, wordsOfProcess } from './orca.js';
|
|
29
|
+
import { frontOfTab, orcaDefaultArgs, projects, runMissing, tabs, wordsOfProcess } from './orca.js';
|
|
30
30
|
import { permissionsTrouble } from './permissions.js';
|
|
31
31
|
import { TAB_ENV } from './record.js';
|
|
32
32
|
import { agentsTrouble, rulesStamp } from './rules.js';
|
|
@@ -96,7 +96,7 @@ export function orcaSettingFindings() {
|
|
|
96
96
|
const unreadableSettings = (where) => finding(
|
|
97
97
|
'orca',
|
|
98
98
|
where,
|
|
99
|
-
`The kit could not read Orca's own settings at ${where}, so it cannot say whether Orca adds default launch arguments of its own. When those carry a permission bypass, every session Orca launches, relaunches or resumes runs in that mode whatever approval level the kit asked for.`,
|
|
99
|
+
`The kit could not read Orca's own settings at ${where}, so it cannot say whether Orca adds default launch arguments of its own. It looks for them in profile-state.db in each Orca profile, or in orca-data.json for an older Orca. When those carry a permission bypass, every session Orca launches, relaunches or resumes runs in that mode whatever approval level the kit asked for.`,
|
|
100
100
|
);
|
|
101
101
|
|
|
102
102
|
/** Whether a line of arguments carries `flag` as a word of its own. */
|
|
@@ -387,6 +387,21 @@ function inOrca(bots, home, bot, setups, sessions) {
|
|
|
387
387
|
found.push(finding('session', entry.tab, `${bot.name}'s session ${name} is in the book with tab ${entry.tab}, and Orca has no tab of that id: the tab was closed, or the machine was restarted. The conversation is in the book, and obk up opens a tab and brings it back.`, bot.name));
|
|
388
388
|
}
|
|
389
389
|
|
|
390
|
+
// A Run Orca does not have, as when the book was written on another
|
|
391
|
+
// machine: mail sent to it is refused. The session's next start makes it
|
|
392
|
+
// a new one (#508).
|
|
393
|
+
if (typeof entry?.mailbox === 'string' && runMissing(entry.mailbox)) {
|
|
394
|
+
// The bot's unpause leaves a session paused on its own as it is, so a
|
|
395
|
+
// session under both marks needs both commands.
|
|
396
|
+
const unpause = `${shellWord(ownCli())} unpause --bots ${shellWord(bots)} --bot ${bot.name}`;
|
|
397
|
+
const steps = [
|
|
398
|
+
...(bot.paused === true ? [unpause] : []),
|
|
399
|
+
...(bot.sessions.some((session) => session.name === name && session.paused === true) ? [`${unpause} --session ${name}`] : []),
|
|
400
|
+
];
|
|
401
|
+
const fix = steps.length > 0 ? steps.join(', then ') : `${shellWord(ownCli())} restart --bots ${shellWord(bots)} --bot ${bot.name} --session ${name}`;
|
|
402
|
+
found.push(finding('session', bookFile(home), `${bot.name}'s session ${name} has the mailbox ${entry.mailbox} in the book, and Orca has no Run of that id: it was made by another Orca, as on another machine, so mail sent to it is refused. The session's next start makes it a new one: ${fix}`, bot.name));
|
|
403
|
+
}
|
|
404
|
+
|
|
390
405
|
const unclaimed = (Array.isArray(entry?.unclaimed) ? entry.unclaimed : []).filter((id) => !helpers.has(id));
|
|
391
406
|
if (unclaimed.length > 0) {
|
|
392
407
|
found.push(finding('leftover', bookFile(home), `The book notes conversations under ${bot.name}'s session ${name} that no session of this bot claims: ${unclaimed.join(', ')}. They ran in this bot's folder, and the kit will not say whose they were. To give one back, write it into ${bookFile(home)} under ${name} as session: <id> and run obk up again.`, bot.name));
|
package/src/hooks.js
CHANGED
|
@@ -54,6 +54,20 @@ const SENT_MATCHER = 'SendMessage';
|
|
|
54
54
|
export const sentCommand = (bots, bot, cli = ownCli()) =>
|
|
55
55
|
`${shellWord(cli)} session sent --bots ${shellWord(bots)} --bot ${shellWord(bot)} 2>/dev/null || true`;
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Claude Code's third kit hook: at each turn end, it tells the session once
|
|
59
|
+
* about fleet mail the kit sent it that is still unread (#509, ADR 0035).
|
|
60
|
+
* Claude Code only: on Codex a new hook entry puts every session on "Hooks
|
|
61
|
+
* need review", and that waits for #511. It asks Orca only when the kit's hint
|
|
62
|
+
* holds mail for the session, and gives Orca at most ten seconds of its thirty.
|
|
63
|
+
*/
|
|
64
|
+
const MAIL_EVENT = 'Stop';
|
|
65
|
+
const MAIL_TIMEOUT = 30;
|
|
66
|
+
|
|
67
|
+
/** What the mail hook runs, by the kit's own path, as `hookCommand` does. */
|
|
68
|
+
export const mailCommand = (bots, bot, cli = ownCli()) =>
|
|
69
|
+
`${shellWord(cli)} session mail --bots ${shellWord(bots)} --bot ${shellWord(bot)} 2>/dev/null || true`;
|
|
70
|
+
|
|
57
71
|
/**
|
|
58
72
|
* Codex's second kit hook: after each command its shell tool ran, outside
|
|
59
73
|
* Codex's sandbox, it decides the mail nudges a send in that command could not
|
|
@@ -250,7 +264,7 @@ function withKitHook(hooks, file, mine) {
|
|
|
250
264
|
|
|
251
265
|
/** Each harness's kit hooks beside its session hook, as `toolHookOf` gives each. */
|
|
252
266
|
const toolHooksOf = (harness, bots, bot, cli = ownCli()) =>
|
|
253
|
-
(harness === 'claude' ? ['sent'] : ['nudge', 'name']).map((kind) => toolHookOf(kind, bots, bot, cli));
|
|
267
|
+
(harness === 'claude' ? ['sent', 'mail'] : ['nudge', 'name']).map((kind) => toolHookOf(kind, bots, bot, cli));
|
|
254
268
|
|
|
255
269
|
/**
|
|
256
270
|
* One of the kit's hooks beside its session hook: Claude Code's after a native
|
|
@@ -259,6 +273,15 @@ const toolHooksOf = (harness, bots, bot, cli = ownCli()) =>
|
|
|
259
273
|
* goes, how the kit knows its own, and what is lost without it.
|
|
260
274
|
*/
|
|
261
275
|
function toolHookOf(kind, bots, bot, cli = ownCli()) {
|
|
276
|
+
if (kind === 'mail') {
|
|
277
|
+
return {
|
|
278
|
+
kind,
|
|
279
|
+
event: MAIL_EVENT,
|
|
280
|
+
pattern: KIT_MAIL,
|
|
281
|
+
mine: { type: 'command', command: mailCommand(bots, bot, cli), timeout: MAIL_TIMEOUT },
|
|
282
|
+
missing: 'a Claude session busy when fleet mail came is never told about it at the end of its turn, and nothing is typed into its tab either (#509)',
|
|
283
|
+
};
|
|
284
|
+
}
|
|
262
285
|
if (kind === 'name') {
|
|
263
286
|
return {
|
|
264
287
|
kind,
|
|
@@ -355,6 +378,7 @@ const WORD = String.raw`(?:[A-Za-z0-9,._+:@%/=-]+|'(?:[^']|'\\'')*')`;
|
|
|
355
378
|
const KIT_HOOK = new RegExp(String.raw`^(${WORD}) session record --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
|
|
356
379
|
const KIT_SENT = new RegExp(String.raw`^(${WORD}) session sent --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
|
|
357
380
|
const KIT_NUDGE = new RegExp(String.raw`^(${WORD}) session nudge --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
|
|
381
|
+
const KIT_MAIL = new RegExp(String.raw`^(${WORD}) session mail --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
|
|
358
382
|
const KIT_NAME = new RegExp(String.raw`^(${WORD}) session name --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
|
|
359
383
|
|
|
360
384
|
/** The program a bot made before the kit named itself by path runs. */
|
package/src/launch.js
CHANGED
|
@@ -51,7 +51,7 @@ export const harnessOf = (session, botHarness) => (set(session.harness) ? sessio
|
|
|
51
51
|
|
|
52
52
|
/**
|
|
53
53
|
* A session's name on Claude Code, which is also the address another Claude
|
|
54
|
-
* session writes to (ADR
|
|
54
|
+
* session writes to (ADR 0035). It goes on the launch line as `-n`, and it is
|
|
55
55
|
* re-applied every time the session is started: a resume keeps the name by
|
|
56
56
|
* itself (tech notes, section 2), and the kit does not depend on that.
|
|
57
57
|
*
|
|
@@ -224,7 +224,7 @@ export function launchCommand(session, { harness, home, workDir, prompt, promptF
|
|
|
224
224
|
|
|
225
225
|
if (harness === 'claude') {
|
|
226
226
|
// The name is the address other Claude sessions write to, so it goes on
|
|
227
|
-
// every launch line, a resume included (ADR
|
|
227
|
+
// every launch line, a resume included (ADR 0035).
|
|
228
228
|
if (address !== undefined) words.push('-n', address);
|
|
229
229
|
// The context window rides on the model name: `sonnet[1m]`.
|
|
230
230
|
if (set(session.model)) words.push('--model', set(session.context) ? `${session.model}[${session.context}]` : session.model);
|