@assuredloop/orca-bot-kit 0.26.1 → 0.28.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 +68 -41
- package/SETUP.md +26 -13
- package/package.json +1 -1
- package/rules/mail.md +8 -8
- package/skills/obk-bot-building/SKILL.md +24 -15
- package/skills/obk-grooming/SKILL.md +4 -3
- package/src/book.js +143 -23
- package/src/bot.js +246 -118
- package/src/clear.js +7 -3
- package/src/cli.js +129 -130
- package/src/commands.js +21 -14
- package/src/conversations.js +73 -40
- package/src/groom.js +27 -9
- package/src/health.js +79 -11
- package/src/hooks.js +42 -64
- package/src/init.js +36 -29
- package/src/launch.js +221 -20
- package/src/message.js +83 -335
- package/src/name.js +1 -1
- package/src/orca-reload.applescript +1 -1
- package/src/orca-runtime.cjs +1 -1
- package/src/orca.js +35 -42
- package/src/permissions.js +30 -16
- package/src/pi.js +55 -0
- package/src/processes.js +392 -0
- package/src/retire.js +43 -40
- package/src/rules.js +5 -2
- package/src/skills.js +51 -35
- package/src/sources.js +73 -4
- package/src/temp.js +65 -20
- package/src/up.js +80 -14
- 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,19 @@
|
|
|
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. Orca gives no notice to a tab its
|
|
17
|
+
// window has not loaded, so the send says when the receiver's is one (#579).
|
|
18
|
+
|
|
19
|
+
import { mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
22
20
|
import path from 'node:path';
|
|
23
|
-
import { setTimeout as pause } from 'node:timers/promises';
|
|
24
21
|
|
|
25
22
|
import { MAILBOX_WAIT_MS, readBook, takeLineTurn, takeMailboxTurn, TYPING_HELD, TYPING_WAIT_MS } from './book.js';
|
|
26
23
|
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';
|
|
24
|
+
import { harnessOf, isAddressOf, kitFolders, ownCli, reachesMail, SHELL_ENV, shellWord } from './launch.js';
|
|
25
|
+
import { ackMailbox, coordinatorOf, postMessage, pressEscape, readMailbox, tabs, tabToTypeInto, TERMINAL_ENV, TIMED_OUT, useMailbox } from './orca.js';
|
|
31
26
|
|
|
32
27
|
/**
|
|
33
28
|
* How much of a message travels as itself. Above this it is written to a file
|
|
@@ -41,7 +36,7 @@ import { forgetUnread, markTold, noteUnread, unreadOf } from './unread.js';
|
|
|
41
36
|
*/
|
|
42
37
|
export const INLINE_LIMIT = 4 * 1024;
|
|
43
38
|
|
|
44
|
-
/** How long Orca is given to say whether a tab has a
|
|
39
|
+
/** How long Orca is given to say whether a tab has a busy harness in it to interrupt. */
|
|
45
40
|
const LOOK_MS = 2000;
|
|
46
41
|
|
|
47
42
|
/**
|
|
@@ -79,9 +74,6 @@ export function findSession(bots, target) {
|
|
|
79
74
|
address: isAddressOf(bot.name, session.name, held.address) ? held.address : undefined,
|
|
80
75
|
mailbox: typeof held.mailbox === 'string' ? held.mailbox : undefined,
|
|
81
76
|
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
77
|
trouble: whyNotReachable(bot.name, session, harness, held),
|
|
86
78
|
};
|
|
87
79
|
}
|
|
@@ -127,21 +119,22 @@ export function lookUp(bots, { to: target, from: sender, tab }) {
|
|
|
127
119
|
}
|
|
128
120
|
|
|
129
121
|
/**
|
|
130
|
-
* Send one message down the Orca road
|
|
122
|
+
* Send one message down the Orca road; with `interrupt`, end a busy
|
|
123
|
+
* receiver's turn first.
|
|
131
124
|
*
|
|
132
125
|
* A native pair is not carried here: no command line can send a Claude session's
|
|
133
126
|
* own message. The kit says which address to write to instead, and sends
|
|
134
127
|
* nothing, which is the whole of "the bot never picks the transport".
|
|
135
128
|
*/
|
|
136
|
-
export async function sendMessage(bots, { to: target, from: sender, tab, subject, text, textFile, thread }) {
|
|
137
|
-
const from =
|
|
129
|
+
export async function sendMessage(bots, { to: target, from: sender, tab, subject, text, textFile, thread, interrupt = false }) {
|
|
130
|
+
const from = whoIsSending(bots, sender, tab);
|
|
138
131
|
const to = findSession(bots, target);
|
|
139
132
|
const road = roadBetween(from, to);
|
|
140
133
|
|
|
141
134
|
const answer = {
|
|
142
135
|
bots,
|
|
143
136
|
from: { bot: from.bot, session: from.session },
|
|
144
|
-
to: { bot: to.bot, session: to.session },
|
|
137
|
+
to: { bot: to.bot, session: to.session, harness: to.harness },
|
|
145
138
|
subject,
|
|
146
139
|
...road,
|
|
147
140
|
};
|
|
@@ -167,9 +160,10 @@ export async function sendMessage(bots, { to: target, from: sender, tab, subject
|
|
|
167
160
|
}
|
|
168
161
|
|
|
169
162
|
const written = bodyOf(bots, { from, to, subject, text, textFile });
|
|
170
|
-
//
|
|
171
|
-
|
|
172
|
-
|
|
163
|
+
// Looked at before the post, which is the send's last call to Orca (PRD 6.9).
|
|
164
|
+
const shown = loaded(to);
|
|
165
|
+
// The Escape goes first, so the turn has ended when the letter is in.
|
|
166
|
+
const interrupted = interrupt ? interruptBusy(to) : {};
|
|
173
167
|
const message = postMessage({
|
|
174
168
|
to: road.address,
|
|
175
169
|
from: `run:${from.mailbox}`,
|
|
@@ -177,7 +171,6 @@ export async function sendMessage(bots, { to: target, from: sender, tab, subject
|
|
|
177
171
|
body: written.body,
|
|
178
172
|
thread,
|
|
179
173
|
});
|
|
180
|
-
noteUnread(to.home, to.session, { id: message.id, from: `${from.bot}/${from.session}`, subject, at: message.created_at ?? new Date().toISOString() });
|
|
181
174
|
|
|
182
175
|
return {
|
|
183
176
|
...answer,
|
|
@@ -185,10 +178,31 @@ export async function sendMessage(bots, { to: target, from: sender, tab, subject
|
|
|
185
178
|
id: message.id,
|
|
186
179
|
thread: message.thread_id ?? thread,
|
|
187
180
|
file: written.file,
|
|
188
|
-
...
|
|
181
|
+
...interrupted,
|
|
182
|
+
...shown,
|
|
189
183
|
};
|
|
190
184
|
}
|
|
191
185
|
|
|
186
|
+
/**
|
|
187
|
+
* Whether Orca's window has loaded the receiver's tab, looked at just before
|
|
188
|
+
* the letter goes in. Orca gives its notice only to a pane the window has loaded,
|
|
189
|
+
* and lists one it has not as orphaned, as after a window restart (#579; tech
|
|
190
|
+
* notes, section 1). So an orphaned tab is said, with its handle, and a
|
|
191
|
+
* listing that cannot be read is said too, rather than taken for a loaded tab.
|
|
192
|
+
* The look is made once: a tab orphaned after the send is not seen here.
|
|
193
|
+
*/
|
|
194
|
+
function loaded(to) {
|
|
195
|
+
if (to.tab === undefined) return {};
|
|
196
|
+
let listed;
|
|
197
|
+
try {
|
|
198
|
+
listed = tabs(to.home, { timeoutMs: LOOK_MS });
|
|
199
|
+
} catch (error) {
|
|
200
|
+
return { loadTrouble: error.message };
|
|
201
|
+
}
|
|
202
|
+
const tab = listed.find((one) => one.tabId === to.tab);
|
|
203
|
+
return tab?.orphaned === true ? { orphaned: true, handle: tab.handle } : {};
|
|
204
|
+
}
|
|
205
|
+
|
|
192
206
|
/**
|
|
193
207
|
* What is waiting for a session, and the reading of it.
|
|
194
208
|
*
|
|
@@ -220,10 +234,7 @@ export function checkMail(bots, { bot: botName, session: sessionName, tab, peek
|
|
|
220
234
|
}
|
|
221
235
|
try {
|
|
222
236
|
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;
|
|
237
|
+
return mailOf(bots, who, peek, Date.now() + HOLD_MS);
|
|
227
238
|
} finally {
|
|
228
239
|
turn.release();
|
|
229
240
|
}
|
|
@@ -377,6 +388,22 @@ function whoIsWriting(bots, sender, tab, asked = '--from <bot>/<session>: which
|
|
|
377
388
|
return found;
|
|
378
389
|
}
|
|
379
390
|
|
|
391
|
+
/**
|
|
392
|
+
* The session that is sending. In one of this fleet's tabs it is the tab's own
|
|
393
|
+
* session, and `--from` may name only that one: otherwise a bot could send as
|
|
394
|
+
* another, and the reply and the receiver's check would take it for that one
|
|
395
|
+
* (#570). Outside the fleet's tabs `--from` says who is writing, as before.
|
|
396
|
+
*/
|
|
397
|
+
function whoIsSending(bots, sender, tab) {
|
|
398
|
+
const caller = tab === undefined ? undefined : sessionInTab(bots, tab);
|
|
399
|
+
if (caller === undefined) return whoIsWriting(bots, sender, tab);
|
|
400
|
+
if (sender === undefined) return caller;
|
|
401
|
+
|
|
402
|
+
const named = findSession(bots, sender);
|
|
403
|
+
if (named.bot === caller.bot && named.session === caller.session) return caller;
|
|
404
|
+
throw new Error(`this command runs in ${caller.bot}/${caller.session}'s Orca tab, ${tab}, so it sends as ${caller.bot}/${caller.session} and not as ${named.bot}/${named.session}. Nothing was sent. Leave out --from, or name ${caller.bot}/${caller.session}.`);
|
|
405
|
+
}
|
|
406
|
+
|
|
380
407
|
/**
|
|
381
408
|
* The session the book says is in this Orca tab, wherever in the fleet it is,
|
|
382
409
|
* when the caller is that session.
|
|
@@ -411,6 +438,8 @@ const notTheCodexInTab = (found, tab) =>
|
|
|
411
438
|
/**
|
|
412
439
|
* The message as it will travel: the text itself when it is short enough, and
|
|
413
440
|
* otherwise a file beside the bots folder with a body that names it.
|
|
441
|
+
* The file is in the sender's bot's own folder there, the one a Codex session
|
|
442
|
+
* of that bot may write from inside its sandbox (#534).
|
|
414
443
|
*
|
|
415
444
|
* The file is the kit's own, next to the bots repo the way start prompts are,
|
|
416
445
|
* so a long message is not written into the user's git status and does not
|
|
@@ -424,7 +453,7 @@ function bodyOf(bots, { from, to, subject, text, textFile }) {
|
|
|
424
453
|
if (Buffer.byteLength(said, 'utf8') <= INLINE_LIMIT) return { body: said };
|
|
425
454
|
|
|
426
455
|
const file = path.join(
|
|
427
|
-
|
|
456
|
+
kitFolders(bots, from.bot).messages,
|
|
428
457
|
`${encodeURIComponent(`${from.bot}.${from.session}`)}.${encodeURIComponent(`${to.bot}.${to.session}`)}.${stamp()}.md`,
|
|
429
458
|
);
|
|
430
459
|
mkdirSync(path.dirname(file), { recursive: true });
|
|
@@ -446,323 +475,42 @@ function readText(file) {
|
|
|
446
475
|
const stamp = () => new Date().toISOString().replaceAll(':', '-').replace('.', '-');
|
|
447
476
|
|
|
448
477
|
/**
|
|
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.
|
|
478
|
+
* Interrupt a busy receiver before an urgent letter (#555): one Escape into its
|
|
479
|
+
* tab ends the turn it is having, and Orca's notice then comes at once rather
|
|
480
|
+
* than when that turn ends. Never by default. Only where the gate every key
|
|
481
|
+
* the kit types goes through finds the receiver's harness there and busy: an
|
|
482
|
+
* idle one gets Orca's notice at once anyway, and an Escape into a question on
|
|
483
|
+
* its screen would answer it. Codex keeps a command it was running as a
|
|
484
|
+
* background terminal after an Escape.
|
|
488
485
|
*/
|
|
489
|
-
|
|
490
|
-
if (to.tab === undefined) return {
|
|
491
|
-
|
|
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.
|
|
524
|
-
*/
|
|
525
|
-
export const WATCH_MS = 8000;
|
|
526
|
-
|
|
527
|
-
/** How long one look in the watch asks Orca to wait for an idle tab, and the pause after an idle one. */
|
|
528
|
-
const WATCH_LOOK_MS = 500;
|
|
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.
|
|
486
|
+
function interruptBusy(to) {
|
|
487
|
+
if (to.tab === undefined) return { interrupted: false, interruptTrouble: 'it is not up' };
|
|
488
|
+
// A key typed while the kit types a command one key at a time would land in
|
|
489
|
+
// that command (#480), so the receiver's turn for a line first.
|
|
581
490
|
let turn;
|
|
582
491
|
try {
|
|
583
492
|
turn = takeLineTurn(to.home, to.session, TYPING_WAIT_MS);
|
|
584
493
|
} catch (error) {
|
|
585
|
-
return {
|
|
586
|
-
}
|
|
587
|
-
if (turn === undefined) {
|
|
588
|
-
return { nudged: false, nudgeTrouble: TYPING_HELD };
|
|
494
|
+
return { interrupted: false, interruptTrouble: error.message };
|
|
589
495
|
}
|
|
496
|
+
if (turn === undefined) return { interrupted: false, interruptTrouble: TYPING_HELD };
|
|
590
497
|
try {
|
|
591
498
|
const found = lookAt(to);
|
|
592
|
-
if (found.blocked !== undefined) return {
|
|
593
|
-
|
|
594
|
-
if (found.
|
|
595
|
-
if (found.
|
|
596
|
-
|
|
499
|
+
if (found.blocked !== undefined) return { interrupted: false, interruptTrouble: `its tab has something waiting to be answered (${found.blocked}), so nothing was typed` };
|
|
500
|
+
if (found.unsure !== undefined) return { interrupted: false, interruptTrouble: found.unsure };
|
|
501
|
+
if (found.handle === undefined) return { interrupted: false, interruptTrouble: found.gone ? 'it is not up' : 'no harness is running in its tab' };
|
|
502
|
+
if (found.idle) return { interrupted: false, idle: true };
|
|
503
|
+
pressEscape(found.handle);
|
|
504
|
+
return { interrupted: true };
|
|
597
505
|
} 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 };
|
|
506
|
+
return { interrupted: false, interruptTrouble: error.message };
|
|
604
507
|
} finally {
|
|
605
508
|
turn.release();
|
|
606
509
|
}
|
|
607
510
|
}
|
|
608
511
|
|
|
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
512
|
/**
|
|
765
|
-
* The look before the
|
|
513
|
+
* The look before the interrupt. Orca now and then refuses a handle it has just
|
|
766
514
|
* listed as stale, and a fresh listing hands out one that works (tech notes,
|
|
767
515
|
* section 1), so that refusal earns one more listing and one more look. Only
|
|
768
516
|
* 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);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
-- Run by the kit as `osascript orca-reload.applescript <Orca.app path>`, after
|
|
2
|
-
-- a project was removed (#343, ADR
|
|
2
|
+
-- a project was removed (#343, ADR 0046).
|
|
3
3
|
--
|
|
4
4
|
-- Orca's window keeps a removed project in its sidebar until the window is
|
|
5
5
|
-- rebuilt (stablyai/orca#23224; once a release fixes it, this can go behind a
|
package/src/orca-runtime.cjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// CLI: `ELECTRON_RUN_AS_NODE=1 <Orca> orca-runtime.cjs <client> <method> <params> <wait>`.
|
|
3
3
|
//
|
|
4
4
|
// It makes one call to Orca's runtime, through Orca's own client, for a method
|
|
5
|
-
// Orca's CLI does not offer (ADR
|
|
5
|
+
// Orca's CLI does not offer (ADR 0046): `project.update`, which makes Orca's
|
|
6
6
|
// window read its projects again (#224), and `terminal.inspectProcess`, which
|
|
7
7
|
// says who is in front of a tab where `ps` cannot (#298). `params` is JSON. It
|
|
8
8
|
// prints the runtime's answer as JSON and exits 0 only when the runtime took
|