@assuredloop/orca-bot-kit 0.25.1 → 0.25.3
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/package.json +1 -1
- package/src/clear.js +37 -18
- package/src/cli.js +58 -9
- package/src/conversations.js +37 -0
- package/src/health.js +1 -1
- package/src/hooks.js +25 -1
- package/src/launch.js +2 -2
- package/src/message.js +219 -42
- package/src/orca.js +160 -17
- package/src/retire.js +31 -6
- package/src/unread.js +160 -0
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/package.json
CHANGED
package/src/clear.js
CHANGED
|
@@ -19,7 +19,7 @@ import { botDir, readBot } from './bot.js';
|
|
|
19
19
|
import { claudeTranscript, codexRollout } from './conversations.js';
|
|
20
20
|
import { harnessOf, ownCli, shellWord } from './launch.js';
|
|
21
21
|
import { eachLine, firstLine } from './lines.js';
|
|
22
|
-
import { findProject, orca, QUESTION_ON_SCREEN, questionIn, screenRows, tabs, tabToTypeInto, TIMED_OUT } from './orca.js';
|
|
22
|
+
import { FEEDBACK_PANEL, feedbackPanelIn, findProject, orca, QUESTION_ON_SCREEN, questionIn, screenRows, tabs, tabToTypeInto, TIMED_OUT } from './orca.js';
|
|
23
23
|
import { botsNamed, sessionsOf, typeListLine } from './up.js';
|
|
24
24
|
|
|
25
25
|
/** The harness's own command for each, as typed into its input line. Codex's clear is `/new` (tech notes, section 3). */
|
|
@@ -201,7 +201,7 @@ async function enter(it, verb, before = () => {}) {
|
|
|
201
201
|
if (typing === undefined) throw new Error(`${it.name}: ${TYPING_HELD}. Run this again in a moment.`);
|
|
202
202
|
before();
|
|
203
203
|
},
|
|
204
|
-
cannot: (wrong) => (verb === 'compact' && wrong.menu ? ` ${harnessName(it.harness)} here cannot compact: its menu does not offer ${COMMANDS[verb][it.harness]}.` : ''),
|
|
204
|
+
cannot: (wrong) => (verb === 'compact' && wrong.menu && it.harness === 'codex' ? ` ${harnessName(it.harness)} here cannot compact: its menu does not offer ${COMMANDS[verb][it.harness]}.` : ''),
|
|
205
205
|
});
|
|
206
206
|
return { ...typed, typing };
|
|
207
207
|
} catch (error) {
|
|
@@ -223,14 +223,18 @@ async function enter(it, verb, before = () => {}) {
|
|
|
223
223
|
export async function typeCommand(it, command, { before = () => {}, check = menuWrong, cannot = () => '', deadline = Infinity } = {}) {
|
|
224
224
|
// On Codex, what is typed cannot be read back once its menu is open, so
|
|
225
225
|
// nothing may be in its input line before: no draft for the command to join.
|
|
226
|
-
|
|
227
|
-
|
|
226
|
+
// On Claude Code, Orca gives a draft in the line beside the screen (#510).
|
|
227
|
+
const emptyLine = ({ rows, draft }) => {
|
|
228
|
+
if (it.harness !== 'codex') {
|
|
229
|
+
if (draft !== undefined) throw new Error(`${it.name}: nothing was typed, because its input line holds a draft: it reads "${draft}".${shownEnd(rows)}`);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
228
232
|
const line = rows.findLast((row) => /^ *›/.test(row))?.trim();
|
|
229
233
|
if (!CODEX_EMPTY.includes(line)) {
|
|
230
234
|
throw new Error(`${it.name}: nothing was typed, because its input line is not empty: it reads "${line ?? 'nothing'}".${shownEnd(rows)}`);
|
|
231
235
|
}
|
|
232
236
|
};
|
|
233
|
-
emptyLine(
|
|
237
|
+
emptyLine(await idleTab(it));
|
|
234
238
|
const version = it.harness === 'codex' ? codexVersion(readBook(it.home).sessions[it.session]?.session) : undefined;
|
|
235
239
|
|
|
236
240
|
before();
|
|
@@ -238,7 +242,7 @@ export async function typeCommand(it, command, { before = () => {}, check = menu
|
|
|
238
242
|
// with no wait, before the first key (#480 review).
|
|
239
243
|
const ready = lookAt(it);
|
|
240
244
|
if (ready.why !== undefined) throw new Error(`${it.name}: nothing was typed, because ${ready.why}.`);
|
|
241
|
-
emptyLine(ready
|
|
245
|
+
emptyLine(ready);
|
|
242
246
|
const { handle } = ready;
|
|
243
247
|
const typedAt = Date.now();
|
|
244
248
|
// One character a send, each after a look through the gate, so nothing goes
|
|
@@ -329,7 +333,7 @@ async function idleTab(it) {
|
|
|
329
333
|
}
|
|
330
334
|
|
|
331
335
|
/**
|
|
332
|
-
* One look through the typing gate at the session's tab: `{ handle }` when
|
|
336
|
+
* One look through the typing gate at the session's tab: `{ handle, rows, draft }` when
|
|
333
337
|
* nothing on its screen asks a question and nothing says it is at work, and,
|
|
334
338
|
* with `idle`, Orca's tui-idle answered ok; or `{ why }`, naming the signal,
|
|
335
339
|
* with `moved` when the book holds another tab for the session now, or, where
|
|
@@ -350,8 +354,8 @@ function lookAt(it, { idle = true } = {}) {
|
|
|
350
354
|
const found = tabToTypeInto(it.home, it.tabId, idle ? LOOK_MS : CHAR_LOOK_MS);
|
|
351
355
|
if (found.blocked !== undefined) {
|
|
352
356
|
return {
|
|
353
|
-
why: found.blocked ===
|
|
354
|
-
? 'a question of its harness\'s own is on its screen'
|
|
357
|
+
why: found.blocked === FEEDBACK_PANEL ? PANEL_WHY
|
|
358
|
+
: found.blocked === QUESTION_ON_SCREEN ? 'a question of its harness\'s own is on its screen'
|
|
355
359
|
: `a question is waiting on its screen: Orca says ${found.blocked}`,
|
|
356
360
|
};
|
|
357
361
|
}
|
|
@@ -364,7 +368,7 @@ function lookAt(it, { idle = true } = {}) {
|
|
|
364
368
|
const signal = signalIn(found.rows);
|
|
365
369
|
if (signal !== undefined) return { why: signal };
|
|
366
370
|
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 };
|
|
371
|
+
return { handle: found.handle, rows: found.rows, draft: found.draft };
|
|
368
372
|
}
|
|
369
373
|
|
|
370
374
|
/**
|
|
@@ -384,27 +388,38 @@ async function typedWrong(handle, harness, command, version, check) {
|
|
|
384
388
|
// a question that shows on it stops it at once (review of c1e5ba4).
|
|
385
389
|
const signal = seen.rows === undefined ? undefined : signalIn(seen.rows);
|
|
386
390
|
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);
|
|
391
|
+
const wrong = seen.rows === undefined ? { why: `its screen could not be read (${seen.unreadable})` } : check(seen.rows, harness, command, version, seen.draft);
|
|
388
392
|
if (wrong === undefined || Date.now() >= until) return wrong && { ...wrong, rows: seen.rows };
|
|
389
393
|
await pause(ASK_MS);
|
|
390
394
|
}
|
|
391
395
|
}
|
|
392
396
|
|
|
397
|
+
/**
|
|
398
|
+
* Why nothing goes in while Claude Code's panel of feedback drafts is open
|
|
399
|
+
* (#502): it reads single keys from the input line, and one sends a draft to
|
|
400
|
+
* Anthropic, so only the user answers it.
|
|
401
|
+
*/
|
|
402
|
+
const PANEL_WHY = 'its panel of feedback drafts is open above its input box, and the kit does not answer it, since a draft sent from it goes to Anthropic: it clears once the user reviews, sends or dismisses the drafts in that tab';
|
|
403
|
+
|
|
393
404
|
/** Why `rows` say not to type now, as the look says it, or undefined: a turn at work, or a question. */
|
|
394
405
|
function signalIn(rows) {
|
|
395
406
|
const working = rows.find((row) => AT_WORK.some((marker) => marker.test(row)));
|
|
396
407
|
if (working !== undefined) return `it is busy with a turn: its screen shows "${working.trim()}"`;
|
|
408
|
+
if (feedbackPanelIn(rows)) return PANEL_WHY;
|
|
397
409
|
if (questionIn(rows)) return 'a question of its harness\'s own is on its screen';
|
|
398
410
|
return undefined;
|
|
399
411
|
}
|
|
400
412
|
|
|
401
|
-
function menuWrong(rows, harness, command, version) {
|
|
413
|
+
function menuWrong(rows, harness, command, version, draft) {
|
|
402
414
|
const at = rows.findLastIndex((row) => /^ *[›❯]/.test(row));
|
|
403
415
|
if (at < 0) return { why: 'its screen shows no input line' };
|
|
404
416
|
// Claude Code 2.1.288 puts a non-breaking space after its pointer (live run 4).
|
|
405
417
|
const line = rows[at].replaceAll('\u00a0', ' ').trim();
|
|
406
418
|
if (harness === 'codex') return codexMenuWrong(rows, at, line, command, version);
|
|
407
|
-
|
|
419
|
+
// Orca 1.4.223 gives the line's text as the draft, and Claude Code 2.1.296's
|
|
420
|
+
// line on the screen then reads its pointer alone (#510, probe 4).
|
|
421
|
+
const text = draft === undefined ? line : `${POINTER[harness]} ${draft}`;
|
|
422
|
+
if (text !== `${POINTER[harness]} ${command}`) return { why: `its input line reads "${text}"` };
|
|
408
423
|
return claudeMenuWrong(rows, at, command);
|
|
409
424
|
}
|
|
410
425
|
|
|
@@ -412,15 +427,19 @@ function menuWrong(rows, harness, command, version) {
|
|
|
412
427
|
* Claude Code 2.1.288 draws its slash menu above its input box's top rule, a
|
|
413
428
|
* row of ─: a row per command, starting with it, its description wrapped onto
|
|
414
429
|
* rows set far in (live run 4). No pointer marks a selection, so the first
|
|
415
|
-
* command row there has to be the command.
|
|
430
|
+
* command row there has to be the command. Claude Code 2.1.296 starts the
|
|
431
|
+
* selected row with its pointer, ` ❯ /compact`, and that row has to be the
|
|
432
|
+
* command (#510, probes 1 and 2). The menu's pointer is above the rule, the
|
|
433
|
+
* input line's below it, so the one is never taken for the other.
|
|
416
434
|
*/
|
|
417
435
|
function claudeMenuWrong(rows, at, command) {
|
|
418
436
|
const rule = rows.slice(0, at).findLastIndex((row) => /^\s*─{3}/.test(row));
|
|
419
437
|
const menu = [];
|
|
420
|
-
for (let up = rule - 1; up >= 0 && (/^ {1,4}
|
|
421
|
-
const
|
|
422
|
-
|
|
423
|
-
if (
|
|
438
|
+
for (let up = rule - 1; up >= 0 && (/^ {1,4}(?:❯ +)?\/\S/.test(rows[up]) || /^ {20,}\S/.test(rows[up])); up -= 1) menu.unshift(rows[up]);
|
|
439
|
+
const pointed = menu.find((row) => /^ {1,4}❯ +\//.test(row));
|
|
440
|
+
const selected = pointed === undefined ? menu.find((row) => /^ {1,4}\//.test(row)) : pointed.replace(/^ *❯ +/, '');
|
|
441
|
+
if (rule < 0 || selected === undefined) return { why: 'no menu of commands came up above its input box', menu: true };
|
|
442
|
+
if (selected.trim().split(/\s+/)[0] !== command) return { why: `the ${pointed === undefined ? 'first' : 'selected'} row of its menu is "${selected.trim()}"`, menu: true };
|
|
424
443
|
return undefined;
|
|
425
444
|
}
|
|
426
445
|
|
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,
|
|
@@ -1248,13 +1277,26 @@ function toLines(answer, bots, where) {
|
|
|
1248
1277
|
* about the message.
|
|
1249
1278
|
*/
|
|
1250
1279
|
function nudgeLine(answer, where) {
|
|
1280
|
+
const seconds = answer.watchedMs === undefined ? '' : ` in ${Math.max(Math.round(answer.watchedMs / 1000), 1)} s`;
|
|
1281
|
+
if (answer.signal === 'orca') return ` Orca's notice reached it${seconds}, so no line was typed: it reads the mail from there.`;
|
|
1282
|
+
if (answer.signal === 'hook') {
|
|
1283
|
+
return answer.because === 'busy'
|
|
1284
|
+
? " 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."
|
|
1285
|
+
: ` 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.`;
|
|
1286
|
+
}
|
|
1287
|
+
const why = {
|
|
1288
|
+
'no-turn': `no turn started in its tab in ${WATCH_SECONDS} s, so the kit's line was typed`,
|
|
1289
|
+
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",
|
|
1290
|
+
'other-turn': "a turn of other work started in its tab, not Orca's notice, so the kit's line was typed",
|
|
1291
|
+
}[answer.because];
|
|
1292
|
+
const head = why === undefined ? ' ' : ` ${why}: `;
|
|
1251
1293
|
if (answer.nudged && answer.nudgeWatched === false) {
|
|
1252
|
-
return
|
|
1294
|
+
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
1295
|
}
|
|
1254
1296
|
if (answer.nudged && answer.nudgeUnseen !== undefined) {
|
|
1255
|
-
return
|
|
1297
|
+
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
1298
|
}
|
|
1257
|
-
if (answer.nudged) return
|
|
1299
|
+
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
1300
|
if (answer.blocked !== undefined) {
|
|
1259
1301
|
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
1302
|
}
|
|
@@ -1267,6 +1309,9 @@ function nudgeLine(answer, where) {
|
|
|
1267
1309
|
return ` ${where} is not up, so nothing was typed anywhere: the message waits in its mailbox.`;
|
|
1268
1310
|
}
|
|
1269
1311
|
|
|
1312
|
+
/** The watch before the kit's line, as the send says it (#509, ADR 0035). */
|
|
1313
|
+
const WATCH_SECONDS = WATCH_MS / 1000;
|
|
1314
|
+
|
|
1270
1315
|
/**
|
|
1271
1316
|
* What there is to say about the work dir this command was given: one finding
|
|
1272
1317
|
* when it leads out of the bot home, through `..` or a link or as a path
|
|
@@ -1301,9 +1346,13 @@ const closedLines = (closed) => closed.map((tab) => `closed ${tab.bot} ${tab
|
|
|
1301
1346
|
const withLines = (retiredWith, session) => retiredWith.flatMap((gone) => [
|
|
1302
1347
|
...closedLines(gone.closed),
|
|
1303
1348
|
`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`,
|
|
1349
|
+
...unreadLines(gone.unread),
|
|
1304
1350
|
...leftLines(gone.promptsLeft),
|
|
1305
1351
|
]);
|
|
1306
1352
|
|
|
1353
|
+
/** The mail a retired session did not read, as far as the kit knows (#509). */
|
|
1354
|
+
const unreadLines = (unread, who = 'it') => (unread === undefined ? [] : [`unread ${unreadWords(unread, who)}`]);
|
|
1355
|
+
|
|
1307
1356
|
/** What a retire could not remove, and how to: nothing reads these files now (#393). */
|
|
1308
1357
|
const leftLines = (left = []) => left.flatMap(({ file, reason }) => [
|
|
1309
1358
|
`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
|
@@ -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. */
|
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);
|