@assuredloop/orca-bot-kit 0.26.0 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -30
- package/package.json +1 -1
- package/rules/mail.md +5 -5
- package/skills/obk-bot-building/SKILL.md +5 -2
- package/src/book.js +55 -16
- package/src/bot.js +2 -2
- package/src/cli.js +53 -110
- package/src/commands.js +17 -9
- package/src/conversations.js +0 -37
- package/src/health.js +63 -6
- package/src/hooks.js +35 -63
- package/src/launch.js +21 -4
- package/src/message.js +42 -334
- package/src/name.js +1 -1
- package/src/orca.js +19 -35
- package/src/permissions.js +22 -6
- package/src/processes.js +392 -0
- package/src/retire.js +43 -40
- package/src/skills.js +1 -1
- package/src/temp.js +1 -1
- package/src/up.js +13 -2
- package/src/unread.js +0 -160
package/src/message.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// `obk message`: one session writing to another (PRD 6.9, ADR
|
|
1
|
+
// `obk message`: one session writing to another (PRD 6.9, ADR 0042).
|
|
2
2
|
//
|
|
3
3
|
// Two roads, and the bot never picks. Claude to Claude in the same approval
|
|
4
4
|
// class is the harness's own messaging, which no command line can send for it —
|
|
@@ -10,24 +10,18 @@
|
|
|
10
10
|
// refuses a send once the pane is gone. A Run survives the tab, the relaunch
|
|
11
11
|
// and the restart, so a session's address is its Run (tech notes, section 1).
|
|
12
12
|
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
import { mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
21
|
-
import os from 'node:os';
|
|
13
|
+
// One road and one signal (#555, ADR 0042): the letter goes into the
|
|
14
|
+
// receiver's mailbox, and Orca's own notice tells the receiver to look once it
|
|
15
|
+
// is idle. The kit types nothing into the receiver's tab, except the one
|
|
16
|
+
// Escape a sender asks for with --interrupt.
|
|
17
|
+
|
|
18
|
+
import { mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
22
19
|
import path from 'node:path';
|
|
23
|
-
import { setTimeout as pause } from 'node:timers/promises';
|
|
24
20
|
|
|
25
21
|
import { MAILBOX_WAIT_MS, readBook, takeLineTurn, takeMailboxTurn, TYPING_HELD, TYPING_WAIT_MS } from './book.js';
|
|
26
22
|
import { botDir, botNames, readBot } from './bot.js';
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
29
|
-
import { ackMailbox, coordinatorOf, idleNow, postMessage, readMailbox, tabs, tabToTypeInto, TERMINAL_ENV, TIMED_OUT, typeIntoTab, useMailbox } from './orca.js';
|
|
30
|
-
import { forgetUnread, markTold, noteUnread, unreadOf } from './unread.js';
|
|
23
|
+
import { harnessOf, isAddressOf, kitFolders, ownCli, reachesMail, SHELL_ENV, shellWord } from './launch.js';
|
|
24
|
+
import { ackMailbox, coordinatorOf, postMessage, pressEscape, readMailbox, tabs, tabToTypeInto, TERMINAL_ENV, TIMED_OUT, useMailbox } from './orca.js';
|
|
31
25
|
|
|
32
26
|
/**
|
|
33
27
|
* How much of a message travels as itself. Above this it is written to a file
|
|
@@ -41,7 +35,7 @@ import { forgetUnread, markTold, noteUnread, unreadOf } from './unread.js';
|
|
|
41
35
|
*/
|
|
42
36
|
export const INLINE_LIMIT = 4 * 1024;
|
|
43
37
|
|
|
44
|
-
/** How long Orca is given to say whether a tab has a
|
|
38
|
+
/** How long Orca is given to say whether a tab has a busy harness in it to interrupt. */
|
|
45
39
|
const LOOK_MS = 2000;
|
|
46
40
|
|
|
47
41
|
/**
|
|
@@ -79,9 +73,6 @@ export function findSession(bots, target) {
|
|
|
79
73
|
address: isAddressOf(bot.name, session.name, held.address) ? held.address : undefined,
|
|
80
74
|
mailbox: typeof held.mailbox === 'string' ? held.mailbox : undefined,
|
|
81
75
|
tab: typeof held.tab === 'string' ? held.tab : undefined,
|
|
82
|
-
// The conversation the book holds for it, whose record says whether Orca's
|
|
83
|
-
// notice reached it (#509).
|
|
84
|
-
conversation: typeof held.session === 'string' ? held.session : undefined,
|
|
85
76
|
trouble: whyNotReachable(bot.name, session, harness, held),
|
|
86
77
|
};
|
|
87
78
|
}
|
|
@@ -127,13 +118,14 @@ export function lookUp(bots, { to: target, from: sender, tab }) {
|
|
|
127
118
|
}
|
|
128
119
|
|
|
129
120
|
/**
|
|
130
|
-
* Send one message down the Orca road
|
|
121
|
+
* Send one message down the Orca road; with `interrupt`, end a busy
|
|
122
|
+
* receiver's turn first.
|
|
131
123
|
*
|
|
132
124
|
* A native pair is not carried here: no command line can send a Claude session's
|
|
133
125
|
* own message. The kit says which address to write to instead, and sends
|
|
134
126
|
* nothing, which is the whole of "the bot never picks the transport".
|
|
135
127
|
*/
|
|
136
|
-
export async function sendMessage(bots, { to: target, from: sender, tab, subject, text, textFile, thread }) {
|
|
128
|
+
export async function sendMessage(bots, { to: target, from: sender, tab, subject, text, textFile, thread, interrupt = false }) {
|
|
137
129
|
const from = whoIsWriting(bots, sender, tab);
|
|
138
130
|
const to = findSession(bots, target);
|
|
139
131
|
const road = roadBetween(from, to);
|
|
@@ -141,7 +133,7 @@ export async function sendMessage(bots, { to: target, from: sender, tab, subject
|
|
|
141
133
|
const answer = {
|
|
142
134
|
bots,
|
|
143
135
|
from: { bot: from.bot, session: from.session },
|
|
144
|
-
to: { bot: to.bot, session: to.session },
|
|
136
|
+
to: { bot: to.bot, session: to.session, harness: to.harness },
|
|
145
137
|
subject,
|
|
146
138
|
...road,
|
|
147
139
|
};
|
|
@@ -167,9 +159,8 @@ export async function sendMessage(bots, { to: target, from: sender, tab, subject
|
|
|
167
159
|
}
|
|
168
160
|
|
|
169
161
|
const written = bodyOf(bots, { from, to, subject, text, textFile });
|
|
170
|
-
// The
|
|
171
|
-
|
|
172
|
-
const mark = recordMark(to.harness, to.home, to.conversation);
|
|
162
|
+
// The Escape goes first, so the turn has ended when the letter is in.
|
|
163
|
+
const interrupted = interrupt ? interruptBusy(to) : {};
|
|
173
164
|
const message = postMessage({
|
|
174
165
|
to: road.address,
|
|
175
166
|
from: `run:${from.mailbox}`,
|
|
@@ -177,7 +168,6 @@ export async function sendMessage(bots, { to: target, from: sender, tab, subject
|
|
|
177
168
|
body: written.body,
|
|
178
169
|
thread,
|
|
179
170
|
});
|
|
180
|
-
noteUnread(to.home, to.session, { id: message.id, from: `${from.bot}/${from.session}`, subject, at: message.created_at ?? new Date().toISOString() });
|
|
181
171
|
|
|
182
172
|
return {
|
|
183
173
|
...answer,
|
|
@@ -185,7 +175,7 @@ export async function sendMessage(bots, { to: target, from: sender, tab, subject
|
|
|
185
175
|
id: message.id,
|
|
186
176
|
thread: message.thread_id ?? thread,
|
|
187
177
|
file: written.file,
|
|
188
|
-
...
|
|
178
|
+
...interrupted,
|
|
189
179
|
};
|
|
190
180
|
}
|
|
191
181
|
|
|
@@ -220,10 +210,7 @@ export function checkMail(bots, { bot: botName, session: sessionName, tab, peek
|
|
|
220
210
|
}
|
|
221
211
|
try {
|
|
222
212
|
const who = findSession(bots, `${asked.bot}/${asked.session}`);
|
|
223
|
-
|
|
224
|
-
// What was read is out of the kit's hint of unread mail (#509); a peek reads nothing.
|
|
225
|
-
if (!peek && read.messages.length > 0) forgetUnread(who.home, who.session, read.messages.map((message) => message.id));
|
|
226
|
-
return read;
|
|
213
|
+
return mailOf(bots, who, peek, Date.now() + HOLD_MS);
|
|
227
214
|
} finally {
|
|
228
215
|
turn.release();
|
|
229
216
|
}
|
|
@@ -411,6 +398,8 @@ const notTheCodexInTab = (found, tab) =>
|
|
|
411
398
|
/**
|
|
412
399
|
* The message as it will travel: the text itself when it is short enough, and
|
|
413
400
|
* otherwise a file beside the bots folder with a body that names it.
|
|
401
|
+
* The file is in the sender's bot's own folder there, the one a Codex session
|
|
402
|
+
* of that bot may write from inside its sandbox (#534).
|
|
414
403
|
*
|
|
415
404
|
* The file is the kit's own, next to the bots repo the way start prompts are,
|
|
416
405
|
* so a long message is not written into the user's git status and does not
|
|
@@ -424,7 +413,7 @@ function bodyOf(bots, { from, to, subject, text, textFile }) {
|
|
|
424
413
|
if (Buffer.byteLength(said, 'utf8') <= INLINE_LIMIT) return { body: said };
|
|
425
414
|
|
|
426
415
|
const file = path.join(
|
|
427
|
-
|
|
416
|
+
kitFolders(bots, from.bot).messages,
|
|
428
417
|
`${encodeURIComponent(`${from.bot}.${from.session}`)}.${encodeURIComponent(`${to.bot}.${to.session}`)}.${stamp()}.md`,
|
|
429
418
|
);
|
|
430
419
|
mkdirSync(path.dirname(file), { recursive: true });
|
|
@@ -446,323 +435,42 @@ function readText(file) {
|
|
|
446
435
|
const stamp = () => new Date().toISOString().replaceAll(':', '-').replace('.', '-');
|
|
447
436
|
|
|
448
437
|
/**
|
|
449
|
-
*
|
|
450
|
-
*
|
|
451
|
-
*
|
|
452
|
-
*
|
|
453
|
-
*
|
|
454
|
-
* its
|
|
455
|
-
*
|
|
456
|
-
* typed. When no turn started, the kit types its line, which an idle harness
|
|
457
|
-
* takes at once. A Claude session busy with a turn, from the first look or
|
|
458
|
-
* from a turn of other work in the watch, gets nothing typed: Claude Code
|
|
459
|
-
* holds a line typed during a turn in its input box until the turn ends,
|
|
460
|
-
* which can be after the mail was read, or never, and the session's own
|
|
461
|
-
* turn-end hook tells it about mail still unread. A busy Codex session gets the
|
|
462
|
-
* line: Codex takes it into the running turn at once, and it is what ends
|
|
463
|
-
* Codex's sleep tool (#432). A record that cannot be found or read is a notice
|
|
464
|
-
* not seen.
|
|
465
|
-
*
|
|
466
|
-
* Nothing is typed into a tab with no harness in it — there is nobody there to
|
|
467
|
-
* read it, and the message waits in the mailbox until the session is up. Nor
|
|
468
|
-
* into one the kit cannot tell about. A tab the book does not hold is never
|
|
469
|
-
* typed into on any road.
|
|
470
|
-
*
|
|
471
|
-
* Nor into a tab with something on screen waiting to be answered. A line typed
|
|
472
|
-
* into one of those is not a message: it is an answer to whatever question is
|
|
473
|
-
* up. That is not a worry, it is a thing that happened — in slice 03 a second
|
|
474
|
-
* line went into a tab on Claude Code's folder-trust list, confirmed its
|
|
475
|
-
* default, `No, exit`, and the harness quit (tech notes, section 1). And in
|
|
476
|
-
* #329 a return took Codex's "Update now". So where Orca says a tab is
|
|
477
|
-
* blocked, or the kit sees a numbered choice list of the harness's own on its
|
|
478
|
-
* screen, nothing is typed and the mail waits. The gate is passed again right
|
|
479
|
-
* before the line, after the watch.
|
|
480
|
-
*
|
|
481
|
-
* That narrows the case rather than closing it: a question drawn any other way,
|
|
482
|
-
* such as Claude Code's unnumbered trust list, is seen by neither. What closes
|
|
483
|
-
* it is nobody sending to a session before its first screens are answered,
|
|
484
|
-
* which is the caller's work either way.
|
|
485
|
-
*
|
|
486
|
-
* What is left open: a turn that starts between the gate's last look and the
|
|
487
|
-
* line meets the line, which then waits for that turn to end.
|
|
488
|
-
*/
|
|
489
|
-
async function nudge(to, from, subject, tab, mark = recordMark(to.harness, to.home, to.conversation)) {
|
|
490
|
-
if (to.tab === undefined) return { nudged: false };
|
|
491
|
-
const notice = `orchestration check --run ${to.mailbox}`;
|
|
492
|
-
|
|
493
|
-
const first = atTheGate(to, from, subject, tab, mark, (found) => (found.idle ? { watch: found.handle } : { busy: true }));
|
|
494
|
-
if (first.busy) {
|
|
495
|
-
// Busy at the first look may be Orca's notice, typed the moment the mail
|
|
496
|
-
// was in: an idle Codex took it as a turn before the kit looked, live.
|
|
497
|
-
if (await noticeShows(mark, notice)) return { nudged: false, signal: 'orca' };
|
|
498
|
-
if (to.harness === 'claude') return { nudged: false, signal: 'hook', because: 'busy' };
|
|
499
|
-
return atTheGate(to, from, subject, tab, mark, (found) => (userTurnSince(mark, notice)
|
|
500
|
-
? { nudged: false, signal: 'orca' }
|
|
501
|
-
: line(found.handle, to, from, subject, 'busy')));
|
|
502
|
-
}
|
|
503
|
-
if (first.watch === undefined) return first;
|
|
504
|
-
|
|
505
|
-
const watched = await watch(first.watch, mark, notice);
|
|
506
|
-
const watchedMs = watched.ms;
|
|
507
|
-
if (watched.notice) return { nudged: false, signal: 'orca', watchedMs };
|
|
508
|
-
if (watched.turn && to.harness === 'claude') return { nudged: false, signal: 'hook', because: 'other-turn', watchedMs };
|
|
509
|
-
|
|
510
|
-
const last = atTheGate(to, from, subject, tab, mark, (found) => {
|
|
511
|
-
// What came while the gate looked, said as it was.
|
|
512
|
-
if (userTurnSince(mark, notice)) return { nudged: false, signal: 'orca' };
|
|
513
|
-
const because = watched.turn || !found.idle ? 'other-turn' : 'no-turn';
|
|
514
|
-
if (!found.idle && to.harness === 'claude') return { nudged: false, signal: 'hook', because };
|
|
515
|
-
return line(found.handle, to, from, subject, because);
|
|
516
|
-
});
|
|
517
|
-
return { ...last, watchedMs };
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
/**
|
|
521
|
-
* How long an idle receiver is watched for Orca's notice before the kit types
|
|
522
|
-
* its line, the architect's ruling for #509. The send returns as soon as the
|
|
523
|
-
* notice shows, so only a notice that did not come costs the whole of it.
|
|
438
|
+
* Interrupt a busy receiver before an urgent letter (#555): one Escape into its
|
|
439
|
+
* tab ends the turn it is having, and Orca's notice then comes at once rather
|
|
440
|
+
* than when that turn ends. Never by default. Only where the gate every key
|
|
441
|
+
* the kit types goes through finds the receiver's harness there and busy: an
|
|
442
|
+
* idle one gets Orca's notice at once anyway, and an Escape into a question on
|
|
443
|
+
* its screen would answer it. Codex keeps a command it was running as a
|
|
444
|
+
* background terminal after an Escape.
|
|
524
445
|
*/
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
/**
|
|
531
|
-
* How long the record is read once a turn has started, for the notice that
|
|
532
|
-
* started it: the harness writes the turn as it takes it, and a look can see
|
|
533
|
-
* the turn a moment before the record does.
|
|
534
|
-
*/
|
|
535
|
-
const RECORD_GRACE_MS = 1500;
|
|
536
|
-
|
|
537
|
-
/** How often the record is read in that time. */
|
|
538
|
-
const RECORD_ASK_MS = 250;
|
|
539
|
-
|
|
540
|
-
/**
|
|
541
|
-
* Whether the receiver's record shows Orca's notice within RECORD_GRACE_MS, for
|
|
542
|
-
* a tab seen busy: the most a send to a receiver busy with other work waits.
|
|
543
|
-
*/
|
|
544
|
-
async function noticeShows(mark, notice) {
|
|
545
|
-
for (const end = Date.now() + RECORD_GRACE_MS; ; await pause(RECORD_ASK_MS)) {
|
|
546
|
-
if (userTurnSince(mark, notice)) return true;
|
|
547
|
-
if (Date.now() >= end) return false;
|
|
548
|
-
}
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
/**
|
|
552
|
-
* Watch an idle receiver for up to WATCH_MS: `{ notice: true }` once its record
|
|
553
|
-
* shows Orca's notice, `{ turn: true }` once a turn started that the record
|
|
554
|
-
* does not show as the notice, or `{}` when it stayed at rest; with `ms`, how
|
|
555
|
-
* long it watched. A look Orca does not answer says nothing, and the watch
|
|
556
|
-
* goes on to its end.
|
|
557
|
-
*/
|
|
558
|
-
async function watch(handle, mark, notice) {
|
|
559
|
-
const started = Date.now();
|
|
560
|
-
const until = started + WATCH_MS;
|
|
561
|
-
const ms = () => Date.now() - started;
|
|
562
|
-
for (;;) {
|
|
563
|
-
if (userTurnSince(mark, notice)) return { notice: true, ms: ms() };
|
|
564
|
-
const left = until - Date.now();
|
|
565
|
-
if (left <= 0) return { ms: ms() };
|
|
566
|
-
const seen = idleNow(handle, Math.min(WATCH_LOOK_MS, left));
|
|
567
|
-
if (seen === 'busy') return (await noticeShows(mark, notice)) ? { notice: true, ms: ms() } : { turn: true, ms: ms() };
|
|
568
|
-
if (seen === 'idle') await pause(Math.min(WATCH_LOOK_MS, Math.max(until - Date.now(), 0)));
|
|
569
|
-
}
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
/**
|
|
573
|
-
* Look at the receiver's tab through the gate every typed line goes through,
|
|
574
|
-
* holding its turn for a line, and answer what `then` makes of a tab that
|
|
575
|
-
* passed; a tab that did not pass is answered as it was before #509.
|
|
576
|
-
*/
|
|
577
|
-
function atTheGate(to, from, subject, tab, mark, then) {
|
|
578
|
-
// A line typed while the kit types a command one key at a time would land
|
|
579
|
-
// in it and send it with its own return (#480): so the receiver's turn for
|
|
580
|
-
// a line first, for a bounded time, and the mail waits in its mailbox if not.
|
|
446
|
+
function interruptBusy(to) {
|
|
447
|
+
if (to.tab === undefined) return { interrupted: false, interruptTrouble: 'it is not up' };
|
|
448
|
+
// A key typed while the kit types a command one key at a time would land in
|
|
449
|
+
// that command (#480), so the receiver's turn for a line first.
|
|
581
450
|
let turn;
|
|
582
451
|
try {
|
|
583
452
|
turn = takeLineTurn(to.home, to.session, TYPING_WAIT_MS);
|
|
584
453
|
} catch (error) {
|
|
585
|
-
return {
|
|
586
|
-
}
|
|
587
|
-
if (turn === undefined) {
|
|
588
|
-
return { nudged: false, nudgeTrouble: TYPING_HELD };
|
|
454
|
+
return { interrupted: false, interruptTrouble: error.message };
|
|
589
455
|
}
|
|
456
|
+
if (turn === undefined) return { interrupted: false, interruptTrouble: TYPING_HELD };
|
|
590
457
|
try {
|
|
591
458
|
const found = lookAt(to);
|
|
592
|
-
if (found.blocked !== undefined) return {
|
|
593
|
-
|
|
594
|
-
if (found.
|
|
595
|
-
if (found.
|
|
596
|
-
|
|
459
|
+
if (found.blocked !== undefined) return { interrupted: false, interruptTrouble: `its tab has something waiting to be answered (${found.blocked}), so nothing was typed` };
|
|
460
|
+
if (found.unsure !== undefined) return { interrupted: false, interruptTrouble: found.unsure };
|
|
461
|
+
if (found.handle === undefined) return { interrupted: false, interruptTrouble: found.gone ? 'it is not up' : 'no harness is running in its tab' };
|
|
462
|
+
if (found.idle) return { interrupted: false, idle: true };
|
|
463
|
+
pressEscape(found.handle);
|
|
464
|
+
return { interrupted: true };
|
|
597
465
|
} catch (error) {
|
|
598
|
-
|
|
599
|
-
// about the tab. So this is reported rather than thrown: a send that ends
|
|
600
|
-
// in an error the caller reads as "it did not go" would be a lie, and a
|
|
601
|
-
// silent `false` would read as "the session is not up", which is a
|
|
602
|
-
// different thing from "Orca would not say".
|
|
603
|
-
return { nudged: false, nudgeTrouble: error.message };
|
|
466
|
+
return { interrupted: false, interruptTrouble: error.message };
|
|
604
467
|
} finally {
|
|
605
468
|
turn.release();
|
|
606
469
|
}
|
|
607
470
|
}
|
|
608
471
|
|
|
609
|
-
/** Type the kit's line into the receiver's tab, and say what Orca saw of it. */
|
|
610
|
-
function line(handle, to, from, subject, because) {
|
|
611
|
-
const sent = typeIntoTab(
|
|
612
|
-
handle,
|
|
613
|
-
`Fleet mail from ${from.bot}/${from.session}: ${subject}. Read it with ${shellWord(ownCli())} message check --bots ${shellWord(to.bots)} --bot ${shellWord(to.bot)} --session ${shellWord(to.session)}`,
|
|
614
|
-
{ watch: true },
|
|
615
|
-
);
|
|
616
|
-
const said = { nudged: true, signal: 'line', because };
|
|
617
|
-
// Typed is not taken. A harness busy with a turn queues the line and gives
|
|
618
|
-
// it no turn of its own, and a line can be lost, and Orca's receipt looks
|
|
619
|
-
// the same for both: only a turn start it saw says the line landed (#394).
|
|
620
|
-
if (sent?.send?.prompt?.stages?.includes('turn_started')) return said;
|
|
621
|
-
// Where Orca did not watch the line at all, as when its own notice has
|
|
622
|
-
// just started a turn in the tab, it saw nothing either way, and says so.
|
|
623
|
-
if (sent?.send?.prompt?.observation === 'unsupported') return { ...said, nudgeUnseen: unseen(sent), nudgeWatched: false };
|
|
624
|
-
return { ...said, nudgeUnseen: unseen(sent) };
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
/**
|
|
628
|
-
* The nudge a Codex sender could not decide, left for its own hook (#350, ADR
|
|
629
|
-
* 0034): `{ nudgeLeft: true }`, or nothing when it is not left.
|
|
630
|
-
*
|
|
631
|
-
* Inside Codex's sandbox `ps` does not start, and for a Claude receiver busy
|
|
632
|
-
* with a command Orca's runtime cannot see past it either. Codex runs its
|
|
633
|
-
* hooks outside the sandbox, and the kit's `PostToolUse` hook runs right after
|
|
634
|
-
* the command that made this send, in the same tab, where `ps` runs. So the
|
|
635
|
-
* send leaves the nudge where that hook looks, the system temp folder under the
|
|
636
|
-
* tab's id, which the sandbox lets it write and the hook reads. Only for a
|
|
637
|
-
* Codex session sending from its own tab, and only where `ps` could not read
|
|
638
|
-
* the receiver's tab: anywhere else no hook would see it, or would see no more
|
|
639
|
-
* than the send did.
|
|
640
|
-
*/
|
|
641
|
-
function leftForHook(to, from, subject, tab, mark) {
|
|
642
|
-
if (tab === undefined || from.tab !== tab || from.harness !== 'codex') return {};
|
|
643
|
-
try {
|
|
644
|
-
const dir = leftDir(tab);
|
|
645
|
-
mkdirSync(dir, { recursive: true });
|
|
646
|
-
// The receiver's record as it stood before the post, so the hook reads
|
|
647
|
-
// what came after the post and not after itself (#509 review): Orca's
|
|
648
|
-
// notice can land before the hook runs. None stays none.
|
|
649
|
-
const left = { bots: to.bots, to: `${to.bot}/${to.session}`, from: `${from.bot}/${from.session}`, subject, mark: mark ?? null };
|
|
650
|
-
// Written under a name the hook does not take, then put in place whole: a
|
|
651
|
-
// hook running beside this send never takes a file still being written.
|
|
652
|
-
const name = path.join(dir, `${stamp()}.${process.pid}`);
|
|
653
|
-
writeFileSync(`${name}.writing`, `${JSON.stringify(left)}\n`);
|
|
654
|
-
renameSync(`${name}.writing`, `${name}.json`);
|
|
655
|
-
return { nudgeLeft: true };
|
|
656
|
-
} catch {
|
|
657
|
-
return {};
|
|
658
|
-
}
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
/** Where the nudges left from one tab wait for its hook. */
|
|
662
|
-
const leftDir = (tab) => path.join(os.tmpdir(), 'obk-nudges', encodeURIComponent(tab));
|
|
663
|
-
|
|
664
|
-
/**
|
|
665
|
-
* What the kit's Codex hook does after each shell command (#350): decide every
|
|
666
|
-
* nudge a send in this tab left, oldest first, with the gate the send uses, and
|
|
667
|
-
* type it or not. Each is taken out before it is looked at, so it is decided
|
|
668
|
-
* once, by one hook. Returns `{ to, subject, ...what the nudge came to }` for
|
|
669
|
-
* each, as the send's own answer says it.
|
|
670
|
-
*/
|
|
671
|
-
export async function decideLeftNudges(tab) {
|
|
672
|
-
if (tab === undefined) return [];
|
|
673
|
-
const dir = leftDir(tab);
|
|
674
|
-
let names;
|
|
675
|
-
try {
|
|
676
|
-
names = readdirSync(dir).filter((name) => name.endsWith('.json')).sort();
|
|
677
|
-
} catch {
|
|
678
|
-
return [];
|
|
679
|
-
}
|
|
680
|
-
const decided = [];
|
|
681
|
-
for (const name of names) {
|
|
682
|
-
const taking = path.join(dir, `${name}.taking`);
|
|
683
|
-
// Another hook took it first: it is that hook's to decide, and to remove.
|
|
684
|
-
try {
|
|
685
|
-
renameSync(path.join(dir, name), taking);
|
|
686
|
-
} catch {
|
|
687
|
-
continue;
|
|
688
|
-
}
|
|
689
|
-
let left;
|
|
690
|
-
try {
|
|
691
|
-
left = JSON.parse(readFileSync(taking, 'utf8'));
|
|
692
|
-
} catch {
|
|
693
|
-
continue;
|
|
694
|
-
} finally {
|
|
695
|
-
rmSync(taking, { force: true });
|
|
696
|
-
}
|
|
697
|
-
try {
|
|
698
|
-
const to = findSession(left.bots, left.to);
|
|
699
|
-
const from = findSession(left.bots, left.from);
|
|
700
|
-
decided.push({ to: left.to, subject: left.subject, ...(await nudge(to, from, left.subject, undefined, left.mark ?? null)) });
|
|
701
|
-
} catch (error) {
|
|
702
|
-
decided.push({ to: left.to, subject: left.subject, nudged: false, nudgeTrouble: error.message });
|
|
703
|
-
}
|
|
704
|
-
}
|
|
705
|
-
return decided;
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
/**
|
|
709
|
-
* What the kit's Claude Code hook says at the end of a session's turn (#509,
|
|
710
|
-
* ADR 0035): the reason the turn goes on with, naming each message the kit sent
|
|
711
|
-
* this session that Orca still lists as unread, once for each; or undefined,
|
|
712
|
-
* and the turn ends as it would have.
|
|
713
|
-
*
|
|
714
|
-
* It must never hold a session. So it says nothing when Claude Code says a stop
|
|
715
|
-
* hook is already active, when the kit's hint holds nothing new for the
|
|
716
|
-
* session (and then it asks Orca nothing), when it cannot ask Orca or read the
|
|
717
|
-
* hint, and when it cannot first mark what it tells as told: a message is told
|
|
718
|
-
* once, or not at all. It reads the mailbox only by a peek, as the session's
|
|
719
|
-
* own tab, so nothing is taken as read.
|
|
720
|
-
*/
|
|
721
|
-
export function stillUnread(bots, bot, said, tab, handle) {
|
|
722
|
-
if (said?.hook_event_name !== 'Stop' || said.stop_hook_active === true) return undefined;
|
|
723
|
-
if (tab === undefined || handle === undefined) return undefined;
|
|
724
|
-
const who = sessionInTab(bots, tab);
|
|
725
|
-
if (who === undefined || who.bot !== bot || who.mailbox === undefined) return undefined;
|
|
726
|
-
// Only the conversation the book holds for this tab: a harness started
|
|
727
|
-
// inside the session runs the same hook in the same tab, and would take the
|
|
728
|
-
// session's one telling (#509 review), as the naming hook guards (#480).
|
|
729
|
-
if (typeof said.session_id !== 'string' || said.session_id !== who.conversation) return undefined;
|
|
730
|
-
const untold = unreadOf(who.home, who.session).filter((entry) => !entry.told);
|
|
731
|
-
if (untold.length === 0) return undefined;
|
|
732
|
-
|
|
733
|
-
const found = readMailbox(who.mailbox, { peek: true, handle }, { timeoutMs: HOOK_ASK_MS });
|
|
734
|
-
const waiting = new Map((found.messages ?? []).map((message) => [message.id, message]));
|
|
735
|
-
const tell = untold.filter((entry) => waiting.has(entry.id));
|
|
736
|
-
if (tell.length === 0) return undefined;
|
|
737
|
-
markTold(who.home, who.session, tell.map((entry) => entry.id));
|
|
738
|
-
|
|
739
|
-
const lines = tell.map((entry) => `- from ${entry.from}: "${entry.subject}", which came at ${clockOf(waiting.get(entry.id).created_at ?? entry.at)}`);
|
|
740
|
-
return [
|
|
741
|
-
`${tell.length === 1 ? 'A fleet mail sent to you is' : `${tell.length} fleet mails sent to you are`} still unread. This is not new mail; it is told once:`,
|
|
742
|
-
...lines,
|
|
743
|
-
`Read it with ${shellWord(ownCli())} message check --bots ${shellWord(who.bots)} --bot ${shellWord(who.bot)} --session ${shellWord(who.session)}`,
|
|
744
|
-
].join('\n');
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
/** How long the turn-end hook gives Orca, well inside the 30 s the harness gives the hook. */
|
|
748
|
-
const HOOK_ASK_MS = 10_000;
|
|
749
|
-
|
|
750
|
-
/** A time as a clock reads it, in UTC; what cannot be read is said as it is. */
|
|
751
|
-
function clockOf(at) {
|
|
752
|
-
const when = new Date(typeof at === 'string' && /^\d{4}-\d\d-\d\d \d/.test(at) ? `${at.replace(' ', 'T')}Z` : at);
|
|
753
|
-
return Number.isNaN(when.getTime()) ? String(at) : `${when.toISOString().slice(11, 16)} UTC`;
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
/** Orca's words about a line it did not see start a turn, or what its receipt says when it gives none. */
|
|
757
|
-
function unseen(sent) {
|
|
758
|
-
const warnings = Array.isArray(sent?.warnings) ? sent.warnings.filter((warning) => typeof warning === 'string') : [];
|
|
759
|
-
if (warnings.length > 0) return warnings.join(' ');
|
|
760
|
-
const stages = sent?.send?.prompt?.stages;
|
|
761
|
-
return `Orca's receipt shows ${Array.isArray(stages) ? stages.join(', ') : 'no stages'} and no turn start`;
|
|
762
|
-
}
|
|
763
|
-
|
|
764
472
|
/**
|
|
765
|
-
* The look before the
|
|
473
|
+
* The look before the interrupt. Orca now and then refuses a handle it has just
|
|
766
474
|
* listed as stale, and a fresh listing hands out one that works (tech notes,
|
|
767
475
|
* section 1), so that refusal earns one more listing and one more look. Only
|
|
768
476
|
* one: a second refusal is reported as it is (#294).
|
package/src/name.js
CHANGED
|
@@ -79,7 +79,7 @@ export async function nameSession(bots, bot, said, tabId) {
|
|
|
79
79
|
const it = { ...sessionToType(bots, bot, session), conversation: thread };
|
|
80
80
|
if (it.tabId !== tabId) return undefined;
|
|
81
81
|
// The session's typing turn, taken once it is idle and not before, so a
|
|
82
|
-
// mail
|
|
82
|
+
// mail interrupt waits seconds for it and not the whole wait for idle. Held by
|
|
83
83
|
// anything else: nothing is typed, and the next turn end tries again.
|
|
84
84
|
const before = () => {
|
|
85
85
|
typing = takeTypingTurn(home, session, 0);
|
package/src/orca.js
CHANGED
|
@@ -426,23 +426,6 @@ export const openTab = (home, title) =>
|
|
|
426
426
|
export const retitleTab = (handle, title) =>
|
|
427
427
|
orca(['terminal', 'rename', '--terminal', handle, '--title', title]).rename;
|
|
428
428
|
|
|
429
|
-
/**
|
|
430
|
-
* One quick look at whether the harness in a tab is at rest, for a caller that
|
|
431
|
-
* already knows a harness is there and is watching for a turn to start (#509):
|
|
432
|
-
* `idle` when Orca's tui-idle answers ok, `busy` when it times out, and
|
|
433
|
-
* `unknown` when Orca refuses or does not answer within `waitMs` and a second
|
|
434
|
-
* more. Never throws.
|
|
435
|
-
*/
|
|
436
|
-
export function idleNow(handle, waitMs) {
|
|
437
|
-
try {
|
|
438
|
-
const answer = ask(['terminal', 'wait', '--terminal', handle, '--for', 'tui-idle', '--timeout-ms', String(waitMs)], waitMs + 1000);
|
|
439
|
-
if (answer.ok === true) return answer.result?.wait?.blockedReason === undefined ? 'idle' : 'unknown';
|
|
440
|
-
return answer.error?.code === 'timeout' ? 'busy' : 'unknown';
|
|
441
|
-
} catch {
|
|
442
|
-
return 'unknown';
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
|
|
446
429
|
/**
|
|
447
430
|
* What can be learned about the harness in a tab: what Orca says, and who is
|
|
448
431
|
* in front of the tab's terminal.
|
|
@@ -637,14 +620,14 @@ function pointedChoiceAt(rows, at) {
|
|
|
637
620
|
* is typed, and the answer says why. `idle` says whether Orca's `tui-idle` wait
|
|
638
621
|
* answered ok, and `rows` is the screen the gate read, for a caller that must
|
|
639
622
|
* not type into a busy harness (#391), with the `draft` Orca gave beside it (#510):
|
|
640
|
-
* `{}` for a tab with no harness in it (none in the book,
|
|
641
|
-
*
|
|
623
|
+
* `{}` for a tab with no harness in it (none in the book, or the shell in
|
|
624
|
+
* front), `{ gone: true }` for one Orca does not list, `{ blocked }` for one with something on screen waiting
|
|
642
625
|
* to be answered, and `{ unsure }`, a sentence, for one the kit cannot tell
|
|
643
626
|
* about, with `psUnread` where `ps` could not read the tab (#350). Orca
|
|
644
627
|
* refusing throws, for the caller to report.
|
|
645
628
|
*
|
|
646
629
|
* The one gate for every line the kit types into a running session: the mail
|
|
647
|
-
*
|
|
630
|
+
* interrupt and the skills reload. A busy harness passes it, since both harnesses
|
|
648
631
|
* take a typed line as their next turn.
|
|
649
632
|
*/
|
|
650
633
|
export function tabToTypeInto(home, tabId, timeoutMs) {
|
|
@@ -655,7 +638,7 @@ export function tabToTypeInto(home, tabId, timeoutMs) {
|
|
|
655
638
|
let seen;
|
|
656
639
|
try {
|
|
657
640
|
live = tabs(home, { timeoutMs: GATE_READ_MS }).find((tab) => tab.tabId === tabId);
|
|
658
|
-
if (live === undefined) return {};
|
|
641
|
+
if (live === undefined) return { gone: true };
|
|
659
642
|
seen = harnessInTab(live.handle, timeoutMs, GATE_READ_MS);
|
|
660
643
|
} catch (error) {
|
|
661
644
|
if (error.code !== TIMED_OUT) throw error;
|
|
@@ -737,7 +720,7 @@ export function frontOfTab(handle) {
|
|
|
737
720
|
}
|
|
738
721
|
|
|
739
722
|
/** The `ps` this run reads. OBK_PS overrides it, as OBK_ORCA does Orca. */
|
|
740
|
-
const psCli = () => process.env.OBK_PS || '/bin/ps';
|
|
723
|
+
export const psCli = () => process.env.OBK_PS || '/bin/ps';
|
|
741
724
|
|
|
742
725
|
/**
|
|
743
726
|
* Who holds the terminal of the tab `handle`, whose pane is `ptyId`:
|
|
@@ -834,7 +817,7 @@ function panePid(ptyId, readMs) {
|
|
|
834
817
|
}
|
|
835
818
|
|
|
836
819
|
/** The `lsof` and `stty` this run asks. OBK_LSOF and OBK_STTY override them, as OBK_PS does `ps`. */
|
|
837
|
-
const lsofCli = () => process.env.OBK_LSOF || '/usr/sbin/lsof';
|
|
820
|
+
export const lsofCli = () => process.env.OBK_LSOF || '/usr/sbin/lsof';
|
|
838
821
|
const sttyCli = () => process.env.OBK_STTY || '/bin/stty';
|
|
839
822
|
|
|
840
823
|
/**
|
|
@@ -968,18 +951,12 @@ export const closeTerminal = (handle) =>
|
|
|
968
951
|
* Seen live on 2026-09-21 in a system test run; not reproduced since, including
|
|
969
952
|
* from a plain shell, long lines and lines sent while the agent was working.
|
|
970
953
|
*
|
|
971
|
-
* The wait is short
|
|
972
|
-
*
|
|
973
|
-
* than a command that hangs for half a minute.
|
|
974
|
-
*
|
|
975
|
-
* With `watch`, the line is sent with that wait from the start, and Orca's
|
|
976
|
-
* receipt says whether it saw the line start a turn (#394). It gives back
|
|
977
|
-
* Orca's `result`: the receipt is in its `send.prompt`, and Orca's words about
|
|
978
|
-
* a turn it did not see in its `warnings`.
|
|
954
|
+
* The wait is short: a line that has to wait is a smaller cost than a
|
|
955
|
+
* command that hangs for half a minute.
|
|
979
956
|
*/
|
|
980
|
-
export function typeIntoTab(handle, text
|
|
957
|
+
export function typeIntoTab(handle, text) {
|
|
981
958
|
const args = ['terminal', 'send', '--terminal', handle, '--text', text, '--enter'];
|
|
982
|
-
const answer = ask(
|
|
959
|
+
const answer = ask(args);
|
|
983
960
|
if (answer.ok === true) return answer.result;
|
|
984
961
|
|
|
985
962
|
const again = answer.error?.data?.orchestrationRequestId;
|
|
@@ -990,11 +967,18 @@ export function typeIntoTab(handle, text, { watch = false } = {}) {
|
|
|
990
967
|
}
|
|
991
968
|
|
|
992
969
|
/**
|
|
993
|
-
* How long a
|
|
994
|
-
*
|
|
970
|
+
* How long a re-issued line is given to be submitted before Orca gives up on
|
|
971
|
+
* it. An idle harness started its turn in about 2 s, live (#394).
|
|
995
972
|
*/
|
|
996
973
|
const SUBMIT_WAIT_S = 5;
|
|
997
974
|
|
|
975
|
+
/**
|
|
976
|
+
* Press Escape once in a tab, with no return after it: what `obk message send
|
|
977
|
+
* --interrupt` sends to a busy receiver (#555).
|
|
978
|
+
*/
|
|
979
|
+
export const pressEscape = (handle) =>
|
|
980
|
+
orca(['terminal', 'send', '--terminal', handle, '--text', '\x1b']);
|
|
981
|
+
|
|
998
982
|
/** Where Orca names the terminal a process runs in, in every pane it opens. */
|
|
999
983
|
export const TERMINAL_ENV = 'ORCA_TERMINAL_HANDLE';
|
|
1000
984
|
|