@assuredloop/orca-bot-kit 0.21.0 → 0.22.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 +8 -0
- package/package.json +1 -1
- package/src/book.js +2 -1
- package/src/bot.js +78 -1
- package/src/cli.js +75 -6
- package/src/health.js +12 -1
- package/src/hooks.js +70 -32
- package/src/message.js +81 -4
- package/src/orca-reload.applescript +1 -1
- package/src/orca-runtime.cjs +1 -1
- package/src/orca.js +12 -10
- package/src/temp.js +156 -32
package/README.md
CHANGED
|
@@ -350,6 +350,14 @@ 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
|
+
A Codex bot gets one more hook too. Inside Codex's sandbox the kit cannot always
|
|
354
|
+
tell whether the receiver of a message has its harness in front, as for a Claude
|
|
355
|
+
session busy running a command, so `obk message send` leaves that nudge, and the
|
|
356
|
+
hook, which Codex runs outside its sandbox right after the command, decides it and
|
|
357
|
+
types it, or says why not ([ADR 0034](docs/adr/0034-orca-is-the-host.md)). Codex
|
|
358
|
+
asks you to trust a hook it has not seen before, so a Codex bot made before this
|
|
359
|
+
hook asks again once.
|
|
360
|
+
|
|
353
361
|
So: kill a tab, or reboot, and `obk up` brings the session back with the
|
|
354
362
|
conversation it was having, rather than starting a new one. Clear a session —
|
|
355
363
|
`/clear` on Claude Code, `/new` on Codex — and the kit writes down the new id,
|
package/package.json
CHANGED
package/src/book.js
CHANGED
|
@@ -32,7 +32,8 @@ const HEADER = `# What Orca calls this bot on this machine, and where each of it
|
|
|
32
32
|
# the bot, with when, so the conversations it had are still accounted for.
|
|
33
33
|
#
|
|
34
34
|
# \`temporary\` marks a session another session of this bot made with
|
|
35
|
-
# \`obk temp make\`: which session made it, and when
|
|
35
|
+
# \`obk temp make\`: which session made it, and when, and the role and option
|
|
36
|
+
# of bot.yaml's temp_roles it was made in, if any. Its maker retires it.
|
|
36
37
|
`;
|
|
37
38
|
|
|
38
39
|
export const bookFile = (home) => path.join(home, 'sessions.yaml');
|
package/src/bot.js
CHANGED
|
@@ -159,6 +159,8 @@ export function readBot(home, name = path.basename(home)) {
|
|
|
159
159
|
// The permission rules the user said yes to, judged where they are written
|
|
160
160
|
// (src/permissions.js), as `rules` is judged by the build.
|
|
161
161
|
allow: bot.allow,
|
|
162
|
+
// The roles its temporary sessions can be made in, judged by tempRoles.
|
|
163
|
+
...(bot.temp_roles === undefined ? {} : { temp_roles: bot.temp_roles }),
|
|
162
164
|
// A paused bot is one `obk up` leaves closed; its sessions keep their book.
|
|
163
165
|
...(bot.paused === true ? { paused: true } : {}),
|
|
164
166
|
sessions: sessions.map((session) => {
|
|
@@ -495,7 +497,7 @@ export const SESSION_FIELDS = [
|
|
|
495
497
|
];
|
|
496
498
|
|
|
497
499
|
/** Everything the top of a bot's own file can hold. */
|
|
498
|
-
const BOT_FIELDS = ['name', 'harness', 'charter', 'rules', 'skills', 'allow', 'sessions', 'paused'];
|
|
500
|
+
const BOT_FIELDS = ['name', 'harness', 'charter', 'rules', 'skills', 'allow', 'sessions', 'paused', 'temp_roles'];
|
|
499
501
|
|
|
500
502
|
/**
|
|
501
503
|
* The keys in a bot's file that the kit does not know, and so that nothing
|
|
@@ -519,6 +521,81 @@ export function unknownKeys(home, name = path.basename(home)) {
|
|
|
519
521
|
];
|
|
520
522
|
}
|
|
521
523
|
|
|
524
|
+
/** What a role's option can set, and what a role written as a mapping can hold (#465). */
|
|
525
|
+
const OPTION_FIELDS = ['name', 'for', 'harness', 'model', 'effort', 'context'];
|
|
526
|
+
const ROLE_FIELDS = ['options', 'cap', 'prompt_file'];
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* The roles a bot's temporary sessions can be made in, from `temp_roles` in its
|
|
530
|
+
* file (#465), each read into one form: `{ name, options, cap, prompt_file,
|
|
531
|
+
* trouble }`. A role is written as a list of options, the first its default,
|
|
532
|
+
* or as a mapping that holds that list as `options` beside a `cap` and a
|
|
533
|
+
* `prompt_file`. `trouble` is each thing wrong with the role, as a sentence;
|
|
534
|
+
* a role with any is not one to make a session in. A `temp_roles` that is not
|
|
535
|
+
* a mapping at all is thrown. The file is the user's, so nothing here is put
|
|
536
|
+
* right: `obk health` says it, and `obk temp make` refuses the role.
|
|
537
|
+
*/
|
|
538
|
+
export function tempRoles(home, bot) {
|
|
539
|
+
const file = path.join(home, BOT_YAML);
|
|
540
|
+
const given = bot.temp_roles;
|
|
541
|
+
if (given === undefined || given === null) return [];
|
|
542
|
+
if (typeof given !== 'object' || Array.isArray(given)) {
|
|
543
|
+
throw new Error(`${file} has a temp_roles that is not a mapping of role names to roles, so the bot has no roles to make a temporary session in.`);
|
|
544
|
+
}
|
|
545
|
+
return Object.entries(given).map(([name, written]) => readRole(home, file, name, written));
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function readRole(home, file, name, written) {
|
|
549
|
+
const trouble = [];
|
|
550
|
+
const say = (what) => trouble.push(`${file}: the temporary-session role ${name} ${what}`);
|
|
551
|
+
const role = Array.isArray(written) ? { options: written } : written;
|
|
552
|
+
if (!NAME.test(name)) say('cannot be a role\'s name: a name is lower-case letters, digits and single hyphens, and it begins the name of each session made in it.');
|
|
553
|
+
if (role === null || typeof role !== 'object') {
|
|
554
|
+
say('is neither a list of options nor a mapping with options in it.');
|
|
555
|
+
return { name, options: [], trouble };
|
|
556
|
+
}
|
|
557
|
+
for (const key of Object.keys(role).filter((one) => !ROLE_FIELDS.includes(one))) {
|
|
558
|
+
say(`has a key the kit does not know, ${key}, so nothing reads it. A role written as a mapping can have ${ROLE_FIELDS.join(', ')}.`);
|
|
559
|
+
}
|
|
560
|
+
const options = Array.isArray(role.options) ? role.options : [];
|
|
561
|
+
if (options.length === 0) say('has no options. Give it a list of them, the first being its default.');
|
|
562
|
+
const seen = new Set();
|
|
563
|
+
options.forEach((option, at) => {
|
|
564
|
+
if (option === null || typeof option !== 'object' || Array.isArray(option) || option.name === undefined || option.name === null || option.name === '') {
|
|
565
|
+
say(`has an option with no name, number ${at + 1} of its options.`);
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
const called = `${name}:${option.name}`;
|
|
569
|
+
if (typeof option.name !== 'string' || !NAME.test(option.name)) say(`has an option ${option.name} whose name is not a word of lower-case letters, digits and single hyphens, so ${called} cannot be asked for. Write the name in quotes if YAML reads it as something else.`);
|
|
570
|
+
if (seen.has(option.name)) say(`has two options called ${option.name}, and ${called} would name either.`);
|
|
571
|
+
seen.add(option.name);
|
|
572
|
+
for (const key of Object.keys(option).filter((one) => !OPTION_FIELDS.includes(one))) {
|
|
573
|
+
say(`has an option ${option.name} with a setting the kit does not know, ${key}, so nothing reads it and a session made in ${called} would not get it. An option can set ${OPTION_FIELDS.join(', ')}.`);
|
|
574
|
+
}
|
|
575
|
+
if (option.harness !== undefined && option.harness !== null && !HARNESSES.includes(option.harness)) {
|
|
576
|
+
say(`has an option ${option.name} that runs on ${option.harness}, and the harnesses are ${HARNESSES.join(' and ')}.`);
|
|
577
|
+
}
|
|
578
|
+
});
|
|
579
|
+
if (role.cap !== undefined && !(Number.isInteger(role.cap) && role.cap > 0)) {
|
|
580
|
+
say(`has a cap of ${JSON.stringify(role.cap)}, and a cap is a whole number of open sessions, 1 or more.`);
|
|
581
|
+
}
|
|
582
|
+
if (role.prompt_file !== undefined) {
|
|
583
|
+
const prompt = path.resolve(home, String(role.prompt_file));
|
|
584
|
+
try {
|
|
585
|
+
readFileSync(prompt, 'utf8');
|
|
586
|
+
} catch (error) {
|
|
587
|
+
say(`has its prompt in ${role.prompt_file}, and that file cannot be read (${error.code}): ${prompt}. Write it, or point the role at the file you meant.`);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
return {
|
|
591
|
+
name,
|
|
592
|
+
options,
|
|
593
|
+
...(role.cap === undefined ? {} : { cap: role.cap }),
|
|
594
|
+
...(role.prompt_file === undefined ? {} : { prompt_file: role.prompt_file }),
|
|
595
|
+
trouble,
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
|
|
522
599
|
/** A session's settings, in that order, without the ones left out. */
|
|
523
600
|
function ordered(session) {
|
|
524
601
|
const entry = {};
|
package/src/cli.js
CHANGED
|
@@ -17,7 +17,7 @@ import { addCommand, groomCommand, grooming, upCommand } from './groom.js';
|
|
|
17
17
|
import { checkHealth, orcaSettingFindings } from './health.js';
|
|
18
18
|
import { initBots } from './init.js';
|
|
19
19
|
import { APPROVALS, HARNESSES, ownCli, shellWord, workDirOf } from './launch.js';
|
|
20
|
-
import { checkMail, lookUp, noMailboxYet, sendMessage, sessionInTab } from './message.js';
|
|
20
|
+
import { checkMail, decideLeftNudges, lookUp, noMailboxYet, sendMessage, sessionInTab } from './message.js';
|
|
21
21
|
import { orcaCli, orcaTrouble, RELOAD_LINE, TERMINAL_ENV } from './orca.js';
|
|
22
22
|
import { allowCommand, allowIn, beyondDefaults, refuseBroad, refuseNoCodexForm, runsOnClaude, runsOnCodex, takeBack, writePermissions } from './permissions.js';
|
|
23
23
|
import { pauseSessions, unpauseSessions } from './pause.js';
|
|
@@ -29,7 +29,7 @@ import { readRoster } from './roster.js';
|
|
|
29
29
|
import { buildAgents, buildRules, CODEX_CAP } from './rules.js';
|
|
30
30
|
import { addSkill, buildSkills, linkSkills, removeSkill } from './skills.js';
|
|
31
31
|
import { addSource, fetchSources } from './sources.js';
|
|
32
|
-
import { makeTemp, retireTemp, trustHooks } from './temp.js';
|
|
32
|
+
import { listRoles, makeTemp, optionsLine, retireTemp, trustHooks } from './temp.js';
|
|
33
33
|
import { readUsage } from './usage.js';
|
|
34
34
|
import { BOT_FATHER, bringUp, ownMailbox } from './up.js';
|
|
35
35
|
|
|
@@ -99,7 +99,7 @@ Usage:
|
|
|
99
99
|
project holds a tab your book does not name, and
|
|
100
100
|
moves the folder only once Orca no longer lists
|
|
101
101
|
the project.
|
|
102
|
-
obk temp make --bots <path> --name <session>
|
|
102
|
+
obk temp make --bots <path> --name <session> [--role <role>[:<option>]]
|
|
103
103
|
(--prompt <text> | --prompt-file <path>) [--harness claude|codex]
|
|
104
104
|
[--model <m>] [--effort <e>] [--context <c>]
|
|
105
105
|
[--approval ${APPROVALS.join('|')}] [--extra-arg=<arg>]...
|
|
@@ -113,6 +113,19 @@ Usage:
|
|
|
113
113
|
work/<session>, and has the task as its start
|
|
114
114
|
prompt. The book records it as temporary, made by
|
|
115
115
|
you.
|
|
116
|
+
--role takes the harness, model, effort and context
|
|
117
|
+
of an option of a role in the bot's temp_roles, the
|
|
118
|
+
role's first option when none is named, and the
|
|
119
|
+
role's prompt file when no task is given; a flag
|
|
120
|
+
still wins, and what the option leaves out is
|
|
121
|
+
taken as above. The session is named
|
|
122
|
+
<role>-<session>. A role's cap, if it has one,
|
|
123
|
+
counts its open sessions. The answer says where
|
|
124
|
+
each setting came from.
|
|
125
|
+
obk temp roles --bots <path>
|
|
126
|
+
Run in a session's own tab: list the roles of your
|
|
127
|
+
bot's temp_roles, each option with what it is for,
|
|
128
|
+
and each role's cap and open sessions.
|
|
116
129
|
obk temp retire --bots <path> --name <session>
|
|
117
130
|
Run in the maker's own tab: retire a temporary
|
|
118
131
|
session it made, as obk retire does. It refuses a
|
|
@@ -242,6 +255,12 @@ Usage:
|
|
|
242
255
|
Claude session's native message, it warns the
|
|
243
256
|
session when the address was none of this bots
|
|
244
257
|
folder's own. It never stops a message.
|
|
258
|
+
obk session nudge --bots <path> --bot <bot>
|
|
259
|
+
For the kit's own hook, not for typing: after a
|
|
260
|
+
Codex session's shell command, it decides the mail
|
|
261
|
+
nudges a send there left because it could not
|
|
262
|
+
tell from inside Codex's sandbox, and types each
|
|
263
|
+
one or says why not.
|
|
245
264
|
obk session mailbox --bots <path> --bot <bot> --session <name>
|
|
246
265
|
For the kit's own launch line, not for typing: run
|
|
247
266
|
in the session's own tab, it gives the session its
|
|
@@ -282,8 +301,10 @@ const COMMANDS = {
|
|
|
282
301
|
'message check': ['bots'],
|
|
283
302
|
'session record': ['bots', 'bot'],
|
|
284
303
|
'session sent': ['bots', 'bot'],
|
|
304
|
+
'session nudge': ['bots', 'bot'],
|
|
285
305
|
'session mailbox': ['bots', 'bot', 'session'],
|
|
286
306
|
'temp make': ['bots', 'name'],
|
|
307
|
+
'temp roles': ['bots'],
|
|
287
308
|
'temp retire': ['bots', 'name'],
|
|
288
309
|
'temp trust-hooks': ['bots', 'name'],
|
|
289
310
|
};
|
|
@@ -306,6 +327,7 @@ const NEEDED = {
|
|
|
306
327
|
to: '--to <bot>/<session>: which session to write to',
|
|
307
328
|
from: '--from <bot>/<session>: which session is writing',
|
|
308
329
|
subject: '--subject <text>: what the message is about',
|
|
330
|
+
role: '--role <role>[:<option>]: which role of the bot\'s temp_roles',
|
|
309
331
|
};
|
|
310
332
|
|
|
311
333
|
/** The flags that name something. A name that is empty names nothing. */
|
|
@@ -360,6 +382,7 @@ async function run(argv) {
|
|
|
360
382
|
...Object.fromEntries(SETTINGS.map(([flag]) => [flag, { type: 'string' }])),
|
|
361
383
|
'extra-arg': { type: 'string', multiple: true },
|
|
362
384
|
'run-on': { type: 'string' },
|
|
385
|
+
role: { type: 'string' },
|
|
363
386
|
json: { type: 'boolean' },
|
|
364
387
|
help: { type: 'boolean', short: 'h' },
|
|
365
388
|
version: { type: 'boolean' },
|
|
@@ -410,6 +433,7 @@ async function run(argv) {
|
|
|
410
433
|
// The send hook is quiet whatever it is given, a bots folder that is not
|
|
411
434
|
// there included, so it goes before anything that can complain (ADR 0032).
|
|
412
435
|
if (command === SENT) return sent(path.resolve(values.bots));
|
|
436
|
+
if (command === NUDGE) return nudgeLeft();
|
|
413
437
|
|
|
414
438
|
// One fleet, one identity, whatever spelling of its path was given (#164).
|
|
415
439
|
const bots = command === 'init' ? path.resolve(values.bots) : sameFleet(path.resolve(values.bots));
|
|
@@ -477,6 +501,7 @@ const ANSWER_IT = [
|
|
|
477
501
|
/** The commands a harness runs rather than a person: the kit's hooks. */
|
|
478
502
|
const RECORD = 'session record';
|
|
479
503
|
const SENT = 'session sent';
|
|
504
|
+
const NUDGE = 'session nudge';
|
|
480
505
|
|
|
481
506
|
/**
|
|
482
507
|
* What the kit's send hook says after a native message: a warning the session
|
|
@@ -495,6 +520,27 @@ function sent(bots) {
|
|
|
495
520
|
return 0;
|
|
496
521
|
}
|
|
497
522
|
|
|
523
|
+
/**
|
|
524
|
+
* What the kit's Codex hook does after a shell command: decide the nudges a
|
|
525
|
+
* send in this tab left for it, outside Codex's sandbox, and say to the session
|
|
526
|
+
* what became of each, as Codex's `additionalContext` (#350, ADR 0034). Silent
|
|
527
|
+
* when nothing was left, never a decision, and never anything but exit 0.
|
|
528
|
+
*/
|
|
529
|
+
function nudgeLeft() {
|
|
530
|
+
try {
|
|
531
|
+
const said = JSON.parse(readFileSync(0, 'utf8'));
|
|
532
|
+
if (said?.hook_event_name !== 'PostToolUse') return 0;
|
|
533
|
+
const decided = decideLeftNudges(process.env[TAB_ENV]);
|
|
534
|
+
if (decided.length > 0) {
|
|
535
|
+
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()}`);
|
|
536
|
+
process.stdout.write(`${JSON.stringify({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: words.join('\n') } })}\n`);
|
|
537
|
+
}
|
|
538
|
+
} catch {
|
|
539
|
+
// Nothing: a hook does not disturb the session it runs in.
|
|
540
|
+
}
|
|
541
|
+
return 0;
|
|
542
|
+
}
|
|
543
|
+
|
|
498
544
|
/**
|
|
499
545
|
* What the kit's hook does with what the harness told it, and what it answers.
|
|
500
546
|
*
|
|
@@ -647,21 +693,41 @@ const commands = {
|
|
|
647
693
|
const tab = callerTab(bots, true);
|
|
648
694
|
refuseWhenOrcaIsDown();
|
|
649
695
|
const given = { name: values.name };
|
|
650
|
-
for (const [flag, key] of [['harness', 'harness'], ['model', 'model'], ['effort', 'effort'], ['approval', 'approval'], ['prompt', 'prompt'], ['prompt-file', 'prompt_file']]) {
|
|
696
|
+
for (const [flag, key] of [['harness', 'harness'], ['model', 'model'], ['effort', 'effort'], ['approval', 'approval'], ['prompt', 'prompt'], ['prompt-file', 'prompt_file'], ['role', 'role']]) {
|
|
651
697
|
if (values[flag] !== undefined) given[key] = values[flag];
|
|
652
698
|
}
|
|
653
699
|
if (values.context !== undefined) given.context = asNumberOrText(values.context);
|
|
654
700
|
if (values['extra-arg'] !== undefined) given.extra_args = values['extra-arg'];
|
|
655
701
|
const made = await makeTemp(bots, { tab, ...given });
|
|
656
702
|
const { tabs, rules, skills, permissions, paused, projects } = made.up;
|
|
657
|
-
const answer = {
|
|
703
|
+
const answer = {
|
|
704
|
+
bots, bot: made.bot, session: made.session, maker: made.maker, ...(made.role === undefined ? {} : { role: made.role }), settings: made.settings, chosen: made.chosen,
|
|
705
|
+
created: [], completed: [], rules, skills, permissions, tabs, paused, projects,
|
|
706
|
+
};
|
|
658
707
|
const retire = `${shellWord(ownCli())} temp retire --bots ${shellWord(bots)} --name ${made.session}`;
|
|
659
708
|
return {
|
|
660
709
|
answer,
|
|
661
|
-
lines:
|
|
710
|
+
lines: [
|
|
711
|
+
...(made.role === undefined ? [] : [`role ${made.role.name}:${made.role.option}${made.role.for === undefined ? '' : `, for ${made.role.for}`}. Its options: ${optionsLine(made.role.options)}`]),
|
|
712
|
+
...Object.entries(made.chosen).map(([setting, one]) => `${setting.padEnd(9)} ${one.value === undefined ? `the ${one.from}` : `${one.value}, from ${one.from === 'flag' ? `--${setting}` : `the ${one.from}`}`}`),
|
|
713
|
+
...tabLines(answer, `Made ${made.bot}/${made.session}, a temporary session of ${made.maker}'s, working in work/${made.session}. Retire it when its work is done: ${retire}`),
|
|
714
|
+
],
|
|
662
715
|
};
|
|
663
716
|
},
|
|
664
717
|
|
|
718
|
+
'temp roles'(bots) {
|
|
719
|
+
const tab = callerTab(bots, true);
|
|
720
|
+
const listed = listRoles(bots, { tab });
|
|
721
|
+
const lines = listed.roles.length === 0
|
|
722
|
+
? [`${listed.bot} has no roles for temporary sessions: its bot.yaml has no temp_roles.`]
|
|
723
|
+
: listed.roles.flatMap((role) => [
|
|
724
|
+
`role ${role.name}${role.cap === undefined ? '' : `, cap ${role.cap}`}, open: ${role.open.length === 0 ? 'none' : role.open.join(', ')}${role.prompt_file === undefined ? '' : `, prompt file ${role.prompt_file}`}`,
|
|
725
|
+
...role.options.map((option) => ` ${option.default ? 'default' : 'option '} ${role.name}:${option.name}${option.for === undefined ? '' : `, for ${option.for}`}: ${['harness', 'model', 'effort', 'context'].filter((key) => option[key] !== undefined).map((key) => `${key} ${option[key]}`).join(', ') || 'all as you have them'}`),
|
|
726
|
+
...(role.trouble ?? []).map((says) => ` trouble ${says}`),
|
|
727
|
+
]);
|
|
728
|
+
return { answer: { bots, ...listed }, lines };
|
|
729
|
+
},
|
|
730
|
+
|
|
665
731
|
async 'temp retire'(bots, values) {
|
|
666
732
|
const tab = callerTab(bots, true);
|
|
667
733
|
refuseWhenOrcaIsDown();
|
|
@@ -1107,6 +1173,9 @@ function nudgeLine(answer, where) {
|
|
|
1107
1173
|
if (answer.blocked !== undefined) {
|
|
1108
1174
|
return ` its tab has something waiting to be answered (${answer.blocked}), so nothing was typed into it. Settle that, and the mail is there.`;
|
|
1109
1175
|
}
|
|
1176
|
+
if (answer.nudgeLeft) {
|
|
1177
|
+
return ` it is queued, and its tab could not be told to look from here: ${answer.nudgeTrouble}. So the nudge is left for this session's hook, which looks at the tab from outside Codex's sandbox when this command is done, and says what it did.`;
|
|
1178
|
+
}
|
|
1110
1179
|
if (answer.nudgeTrouble !== undefined) {
|
|
1111
1180
|
return ` it is queued, and its tab could not be told to look: ${answer.nudgeTrouble}`;
|
|
1112
1181
|
}
|
package/src/health.js
CHANGED
|
@@ -22,7 +22,7 @@ import { existsSync, readdirSync, realpathSync } from 'node:fs';
|
|
|
22
22
|
import path from 'node:path';
|
|
23
23
|
|
|
24
24
|
import { bookFile, readBook, sessionIdsIn, tabIdsIn } from './book.js';
|
|
25
|
-
import { botDir, botNames, botsDir, readBot, unknownKeys } from './bot.js';
|
|
25
|
+
import { botDir, botNames, botsDir, readBot, tempRoles, unknownKeys } from './bot.js';
|
|
26
26
|
import { transcriptsIn } from './conversations.js';
|
|
27
27
|
import { hookTrouble } from './hooks.js';
|
|
28
28
|
import { bypassFlags, harnessOf, HARNESSES, isAddressOf, ownCli, sessionTrouble, SHELL_ENV, shellWord } from './launch.js';
|
|
@@ -190,6 +190,7 @@ function aboutBot(bots, name, setups, sessions) {
|
|
|
190
190
|
return [
|
|
191
191
|
...unknownKeys(home, name).map(said('config')),
|
|
192
192
|
...sessionSettings(home, bot).map(said('config')),
|
|
193
|
+
...rolesTrouble(home, bot).map(said('config')),
|
|
193
194
|
...agentsTrouble(bots, home, bot).map(said('config')),
|
|
194
195
|
...hooksOf(bots, home, bot).map(said('config')),
|
|
195
196
|
...permissionsTrouble(home, bot).map(said('config')),
|
|
@@ -209,6 +210,16 @@ function sessionSettings(home, bot) {
|
|
|
209
210
|
});
|
|
210
211
|
}
|
|
211
212
|
|
|
213
|
+
/** What is wrong with the roles the bot's temporary sessions are made in (#465). */
|
|
214
|
+
function rolesTrouble(home, bot) {
|
|
215
|
+
const where = path.join(home, 'bot.yaml');
|
|
216
|
+
try {
|
|
217
|
+
return tempRoles(home, bot).flatMap((role) => role.trouble.map((says) => ({ where, says })));
|
|
218
|
+
} catch (error) {
|
|
219
|
+
return [{ where, says: error.message }];
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
212
223
|
/** The kit's own hook, in the file of every harness this bot's sessions run on. */
|
|
213
224
|
const hooksOf = (bots, home, bot) => [...new Set(bot.sessions.map((session) => harnessOf(session, bot.harness)))]
|
|
214
225
|
.filter((harness) => HARNESSES.includes(harness))
|
package/src/hooks.js
CHANGED
|
@@ -54,6 +54,21 @@ 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
|
+
* Codex's second kit hook: after each command its shell tool ran, outside
|
|
59
|
+
* Codex's sandbox, it decides the mail nudges a send in that command could not
|
|
60
|
+
* (#350, ADR 0034). Codex only, since only Codex runs its commands where `ps`
|
|
61
|
+
* does not start. It types into another session's tab, which takes Orca a few
|
|
62
|
+
* seconds a nudge, so it is given longer than the others.
|
|
63
|
+
*/
|
|
64
|
+
const NUDGE_EVENT = 'PostToolUse';
|
|
65
|
+
const NUDGE_MATCHER = 'Bash';
|
|
66
|
+
const NUDGE_TIMEOUT = 30;
|
|
67
|
+
|
|
68
|
+
/** What the nudge hook runs, by the kit's own path, as `hookCommand` does. */
|
|
69
|
+
export const nudgeCommand = (bots, bot, cli = ownCli()) =>
|
|
70
|
+
`${shellWord(cli)} session nudge --bots ${shellWord(bots)} --bot ${shellWord(bot)} 2>/dev/null || true`;
|
|
71
|
+
|
|
57
72
|
/**
|
|
58
73
|
* Make sure the bot at `home` has the kit's SessionStart hook for `harness`.
|
|
59
74
|
* Returns the file it wrote, relative to the bot home, or undefined when the
|
|
@@ -76,8 +91,7 @@ export function installHook(home, harness, { bots, bot }) {
|
|
|
76
91
|
// longer asks about is taken out, and a hook of the user's beside the kit's
|
|
77
92
|
// stays where they put it.
|
|
78
93
|
const started = withKitHook(settings.hooks, file, mine);
|
|
79
|
-
const
|
|
80
|
-
const wanted = { ...settings, hooks: harness === 'claude' ? withSentHook(started, sentHook) : started };
|
|
94
|
+
const wanted = { ...settings, hooks: withToolHook(started, toolHookOf(harness, bots, bot)) };
|
|
81
95
|
|
|
82
96
|
// Compared as documents, not as text: how the user laid their file out is
|
|
83
97
|
// theirs, and a run that changes nothing writes nothing.
|
|
@@ -124,24 +138,25 @@ export function hookTrouble(home, harness, { bots, bot }) {
|
|
|
124
138
|
}
|
|
125
139
|
const program = running ?? ownCli();
|
|
126
140
|
wanted = { ...settings, hooks: withKitHook(settings.hooks, file, { type: 'command', command: hookCommand(bots, bot, program), timeout: TIMEOUT }) };
|
|
127
|
-
// The
|
|
128
|
-
// the warning after a native message
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
141
|
+
// The harness's second hook is judged on its own: the session hook may be
|
|
142
|
+
// right while the warning after a native message (ADR 0032), or the nudge
|
|
143
|
+
// a Codex sender left (ADR 0034), is missing.
|
|
144
|
+
if (isDeepStrictEqual(settings, wanted)) {
|
|
145
|
+
const tool = toolHookOf(harness, bots, bot);
|
|
146
|
+
const toolRunning = programIn(settings, tool.pattern);
|
|
147
|
+
// One that runs a kit no longer there is as quiet as a missing one:
|
|
148
|
+
// `|| true` hides it (#451 review).
|
|
149
|
+
if (toolRunning !== undefined && toolRunning !== BARE && !existsSync(toolRunning)) {
|
|
134
150
|
return {
|
|
135
151
|
where: file,
|
|
136
|
-
says: `${file} holds the kit's
|
|
152
|
+
says: `${file} holds the kit's ${tool.event} hook for ${tool.matcher}, but it runs ${toolRunning}, which is not there any more, so ${tool.missing}. obk up puts back one that runs the kit you have.`,
|
|
137
153
|
};
|
|
138
154
|
}
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
if (!isDeepStrictEqual(settings, withSent)) {
|
|
155
|
+
const withTool = { ...settings, hooks: withToolHook(settings.hooks, toolHookOf(harness, bots, bot, toolRunning ?? program)) };
|
|
156
|
+
if (!isDeepStrictEqual(settings, withTool)) {
|
|
142
157
|
return {
|
|
143
158
|
where: file,
|
|
144
|
-
says: `${file} does not hold the kit's
|
|
159
|
+
says: `${file} does not hold the kit's ${tool.event} hook for ${tool.matcher}, without which ${tool.missing}. obk up puts it back.`,
|
|
145
160
|
};
|
|
146
161
|
}
|
|
147
162
|
}
|
|
@@ -216,21 +231,46 @@ function withKitHook(hooks, file, mine) {
|
|
|
216
231
|
}
|
|
217
232
|
|
|
218
233
|
/**
|
|
219
|
-
*
|
|
220
|
-
* `SendMessage
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
* group of its own when there is none. A group the kit empties goes.
|
|
234
|
+
* Each harness's second kit hook, after one of its tools: Claude Code's after a
|
|
235
|
+
* native `SendMessage` (ADR 0032), Codex's after its shell tool (ADR 0034).
|
|
236
|
+
* The entry, where it goes, how the kit knows its own, and what is lost
|
|
237
|
+
* without it.
|
|
224
238
|
*/
|
|
225
|
-
function
|
|
239
|
+
function toolHookOf(harness, bots, bot, cli = ownCli()) {
|
|
240
|
+
if (harness === 'claude') {
|
|
241
|
+
return {
|
|
242
|
+
event: SENT_EVENT,
|
|
243
|
+
matcher: SENT_MATCHER,
|
|
244
|
+
pattern: KIT_SENT,
|
|
245
|
+
mine: { type: 'command', command: sentCommand(bots, bot, cli), timeout: TIMEOUT },
|
|
246
|
+
missing: 'a session is never warned when its native message goes outside this bots folder (ADR 0032)',
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
return {
|
|
250
|
+
event: NUDGE_EVENT,
|
|
251
|
+
matcher: NUDGE_MATCHER,
|
|
252
|
+
pattern: KIT_NUDGE,
|
|
253
|
+
mine: { type: 'command', command: nudgeCommand(bots, bot, cli), timeout: NUDGE_TIMEOUT },
|
|
254
|
+
missing: "mail a session sends from inside Codex's sandbox never tells a receiver the kit could not see from there, such as a Claude session busy with a command (ADR 0034)",
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The hooks with the kit's tool hook in a group of its event matched to its
|
|
260
|
+
* tool, and everything else as the user left it, as `withKitHook` keeps the
|
|
261
|
+
* session hook: one entry of the kit's, written in place where it already sits
|
|
262
|
+
* in such a group, taken out of anywhere else, and added in a group of its own
|
|
263
|
+
* when there is none. A group the kit empties goes.
|
|
264
|
+
*/
|
|
265
|
+
function withToolHook(hooks, { event: wantedEvent, matcher, pattern, mine }) {
|
|
226
266
|
let placed = false;
|
|
227
267
|
const events = Object.fromEntries(Object.entries(hooks).flatMap(([event, groups]) => {
|
|
228
268
|
if (!Array.isArray(groups)) return [[event, groups]];
|
|
229
269
|
const kept = groups.flatMap((group) => {
|
|
230
270
|
if (!Array.isArray(group?.hooks)) return [group];
|
|
231
271
|
const entries = group.hooks.flatMap((hook) => {
|
|
232
|
-
if (
|
|
233
|
-
if (event !==
|
|
272
|
+
if (programOf(hook?.command, pattern) === undefined) return [hook];
|
|
273
|
+
if (event !== wantedEvent || group.matcher !== matcher || placed) return [];
|
|
234
274
|
placed = true;
|
|
235
275
|
return [mine];
|
|
236
276
|
});
|
|
@@ -239,27 +279,24 @@ function withSentHook(hooks, mine) {
|
|
|
239
279
|
return kept.length === 0 ? [] : [[event, kept]];
|
|
240
280
|
}));
|
|
241
281
|
if (placed) return events;
|
|
242
|
-
return { ...events, [
|
|
282
|
+
return { ...events, [wantedEvent]: [...(events[wantedEvent] ?? []), { matcher, hooks: [mine] }] };
|
|
243
283
|
}
|
|
244
284
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
function sentProgramOf(command) {
|
|
250
|
-
const found = typeof command === 'string' ? KIT_SENT.exec(command) : null;
|
|
285
|
+
/** The program a command of the kit's of this pattern runs, unquoted, or undefined for any other command. */
|
|
286
|
+
function programOf(command, pattern) {
|
|
287
|
+
const found = typeof command === 'string' ? pattern.exec(command) : null;
|
|
251
288
|
if (found === null) return undefined;
|
|
252
289
|
const program = unquoted(found[1]);
|
|
253
290
|
return isKitCli(program) ? program : undefined;
|
|
254
291
|
}
|
|
255
292
|
|
|
256
|
-
/** What the kit's
|
|
257
|
-
function
|
|
293
|
+
/** What the kit's hook of this pattern in these settings runs, or undefined when there is none. */
|
|
294
|
+
function programIn(settings, pattern) {
|
|
258
295
|
for (const groups of Object.values(settings.hooks ?? {})) {
|
|
259
296
|
if (!Array.isArray(groups)) continue;
|
|
260
297
|
for (const group of groups) {
|
|
261
298
|
for (const hook of Array.isArray(group?.hooks) ? group.hooks : []) {
|
|
262
|
-
const program =
|
|
299
|
+
const program = programOf(hook?.command, pattern);
|
|
263
300
|
if (program !== undefined) return program;
|
|
264
301
|
}
|
|
265
302
|
}
|
|
@@ -282,6 +319,7 @@ const isKitHook = (hook) => kitProgramOf(hook?.command) !== undefined;
|
|
|
282
319
|
const WORD = String.raw`(?:[A-Za-z0-9,._+:@%/=-]+|'(?:[^']|'\\'')*')`;
|
|
283
320
|
const KIT_HOOK = new RegExp(String.raw`^(${WORD}) session record --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
|
|
284
321
|
const KIT_SENT = new RegExp(String.raw`^(${WORD}) session sent --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
|
|
322
|
+
const KIT_NUDGE = new RegExp(String.raw`^(${WORD}) session nudge --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
|
|
285
323
|
|
|
286
324
|
/** The program a bot made before the kit named itself by path runs. */
|
|
287
325
|
const BARE = 'obk';
|
package/src/message.js
CHANGED
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
// one line into the receiver's tab. Both harnesses queue a typed line while
|
|
16
16
|
// they are busy, which is what PRD 6.9 means by queued and not interrupting.
|
|
17
17
|
|
|
18
|
-
import { mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import os from 'node:os';
|
|
19
20
|
import path from 'node:path';
|
|
20
21
|
|
|
21
22
|
import { MAILBOX_WAIT_MS, readBook, takeMailboxTurn } from './book.js';
|
|
@@ -172,7 +173,7 @@ export function sendMessage(bots, { to: target, from: sender, tab, subject, text
|
|
|
172
173
|
id: message.id,
|
|
173
174
|
thread: message.thread_id ?? thread,
|
|
174
175
|
file: written.file,
|
|
175
|
-
...nudge(to, from, subject),
|
|
176
|
+
...nudge(to, from, subject, tab),
|
|
176
177
|
};
|
|
177
178
|
}
|
|
178
179
|
|
|
@@ -452,14 +453,14 @@ const stamp = () => new Date().toISOString().replaceAll(':', '-').replace('.', '
|
|
|
452
453
|
* it is nobody sending to a session before its first screens are answered,
|
|
453
454
|
* which is the caller's work either way.
|
|
454
455
|
*/
|
|
455
|
-
function nudge(to, from, subject) {
|
|
456
|
+
function nudge(to, from, subject, tab) {
|
|
456
457
|
if (to.tab === undefined) return { nudged: false };
|
|
457
458
|
|
|
458
459
|
try {
|
|
459
460
|
const found = lookAt(to);
|
|
460
461
|
if (found.blocked !== undefined) return { nudged: false, blocked: found.blocked };
|
|
461
462
|
// A line that lands in a shell is run there, with the sender's subject in it.
|
|
462
|
-
if (found.unsure !== undefined) return { nudged: false, nudgeTrouble: found.unsure };
|
|
463
|
+
if (found.unsure !== undefined) return { nudged: false, nudgeTrouble: found.unsure, ...(found.psUnread ? leftForHook(to, from, subject, tab) : {}) };
|
|
463
464
|
if (found.handle === undefined) return { nudged: false };
|
|
464
465
|
|
|
465
466
|
const sent = typeIntoTab(
|
|
@@ -485,6 +486,82 @@ function nudge(to, from, subject) {
|
|
|
485
486
|
}
|
|
486
487
|
}
|
|
487
488
|
|
|
489
|
+
/**
|
|
490
|
+
* The nudge a Codex sender could not decide, left for its own hook (#350, ADR
|
|
491
|
+
* 0034): `{ nudgeLeft: true }`, or nothing when it is not left.
|
|
492
|
+
*
|
|
493
|
+
* Inside Codex's sandbox `ps` does not start, and for a Claude receiver busy
|
|
494
|
+
* with a command Orca's runtime cannot see past it either. Codex runs its
|
|
495
|
+
* hooks outside the sandbox, and the kit's `PostToolUse` hook runs right after
|
|
496
|
+
* the command that made this send, in the same tab, where `ps` runs. So the
|
|
497
|
+
* send leaves the nudge where that hook looks, the system temp folder under the
|
|
498
|
+
* tab's id, which the sandbox lets it write and the hook reads. Only for a
|
|
499
|
+
* Codex session sending from its own tab, and only where `ps` could not read
|
|
500
|
+
* the receiver's tab: anywhere else no hook would see it, or would see no more
|
|
501
|
+
* than the send did.
|
|
502
|
+
*/
|
|
503
|
+
function leftForHook(to, from, subject, tab) {
|
|
504
|
+
if (tab === undefined || from.tab !== tab || from.harness !== 'codex') return {};
|
|
505
|
+
try {
|
|
506
|
+
const dir = leftDir(tab);
|
|
507
|
+
mkdirSync(dir, { recursive: true });
|
|
508
|
+
const left = { bots: to.bots, to: `${to.bot}/${to.session}`, from: `${from.bot}/${from.session}`, subject };
|
|
509
|
+
// Written under a name the hook does not take, then put in place whole: a
|
|
510
|
+
// hook running beside this send never takes a file still being written.
|
|
511
|
+
const name = path.join(dir, `${stamp()}.${process.pid}`);
|
|
512
|
+
writeFileSync(`${name}.writing`, `${JSON.stringify(left)}\n`);
|
|
513
|
+
renameSync(`${name}.writing`, `${name}.json`);
|
|
514
|
+
return { nudgeLeft: true };
|
|
515
|
+
} catch {
|
|
516
|
+
return {};
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** Where the nudges left from one tab wait for its hook. */
|
|
521
|
+
const leftDir = (tab) => path.join(os.tmpdir(), 'obk-nudges', encodeURIComponent(tab));
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* What the kit's Codex hook does after each shell command (#350): decide every
|
|
525
|
+
* nudge a send in this tab left, oldest first, with the gate the send uses, and
|
|
526
|
+
* type it or not. Each is taken out before it is looked at, so it is decided
|
|
527
|
+
* once, by one hook. Returns `{ to, subject, ...what the nudge came to }` for
|
|
528
|
+
* each, as the send's own answer says it.
|
|
529
|
+
*/
|
|
530
|
+
export function decideLeftNudges(tab) {
|
|
531
|
+
if (tab === undefined) return [];
|
|
532
|
+
const dir = leftDir(tab);
|
|
533
|
+
let names;
|
|
534
|
+
try {
|
|
535
|
+
names = readdirSync(dir).filter((name) => name.endsWith('.json')).sort();
|
|
536
|
+
} catch {
|
|
537
|
+
return [];
|
|
538
|
+
}
|
|
539
|
+
return names.flatMap((name) => {
|
|
540
|
+
const taking = path.join(dir, `${name}.taking`);
|
|
541
|
+
// Another hook took it first: it is that hook's to decide, and to remove.
|
|
542
|
+
try {
|
|
543
|
+
renameSync(path.join(dir, name), taking);
|
|
544
|
+
} catch {
|
|
545
|
+
return [];
|
|
546
|
+
}
|
|
547
|
+
let left;
|
|
548
|
+
try {
|
|
549
|
+
left = JSON.parse(readFileSync(taking, 'utf8'));
|
|
550
|
+
} catch {
|
|
551
|
+
return [];
|
|
552
|
+
} finally {
|
|
553
|
+
rmSync(taking, { force: true });
|
|
554
|
+
}
|
|
555
|
+
try {
|
|
556
|
+
const to = findSession(left.bots, left.to);
|
|
557
|
+
const from = findSession(left.bots, left.from);
|
|
558
|
+
return [{ to: left.to, subject: left.subject, ...nudge(to, from, left.subject) }];
|
|
559
|
+
} catch (error) {
|
|
560
|
+
return [{ to: left.to, subject: left.subject, nudged: false, nudgeTrouble: error.message }];
|
|
561
|
+
}
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
|
|
488
565
|
/** Orca's words about a line it did not see start a turn, or what its receipt says when it gives none. */
|
|
489
566
|
function unseen(sent) {
|
|
490
567
|
const warnings = Array.isArray(sent?.warnings) ? sent.warnings.filter((warning) => typeof warning === 'string') : [];
|
|
@@ -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 0034).
|
|
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 0034): `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
|
package/src/orca.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Every call the kit makes to Orca goes through here (ADR
|
|
1
|
+
// Every call the kit makes to Orca goes through here (ADR 0034). Orca's CLI
|
|
2
2
|
// changes often, so the kit reads `--json` and never the human text, and keeps
|
|
3
3
|
// the parsing in one place.
|
|
4
4
|
//
|
|
@@ -216,7 +216,7 @@ const CLIENT_KILL_MS = 3000;
|
|
|
216
216
|
*
|
|
217
217
|
* Orca's window re-reads only when its runtime says the projects changed, and
|
|
218
218
|
* `setup-update` and `setup-delete` do not say so. `project.update` with no
|
|
219
|
-
* changes does, and Orca's CLI does not offer it (ADR
|
|
219
|
+
* changes does, and Orca's CLI does not offer it (ADR 0034). The caller prints
|
|
220
220
|
* `RELOAD_LINE` either way. It does not take a removed project out of the
|
|
221
221
|
* sidebar: `reloadWindow` does.
|
|
222
222
|
*/
|
|
@@ -227,7 +227,7 @@ export const tellWindow = (projectId) => askRuntime('project.update', { projectI
|
|
|
227
227
|
* runtime's answer, or undefined when there is none to be had.
|
|
228
228
|
*
|
|
229
229
|
* It goes through Orca's own runtime client out of the installed app, run by
|
|
230
|
-
* Orca's binary the way its `bin/orca` runs its CLI (ADR
|
|
230
|
+
* Orca's binary the way its `bin/orca` runs its CLI (ADR 0034). None of that is
|
|
231
231
|
* Orca's published interface, so anything that goes wrong is a quiet
|
|
232
232
|
* undefined: it never throws and is never tried twice.
|
|
233
233
|
*/
|
|
@@ -264,7 +264,7 @@ const RELOAD_KILL_MS = 5000;
|
|
|
264
264
|
|
|
265
265
|
/**
|
|
266
266
|
* Have Orca's window force-reload itself, after a project was removed: true
|
|
267
|
-
* only when it did (#343, ADR
|
|
267
|
+
* only when it did (#343, ADR 0034).
|
|
268
268
|
*
|
|
269
269
|
* The window keeps a removed project in its sidebar until it is rebuilt, and
|
|
270
270
|
* `tellWindow` only relabels the row "Unknown" (stablyai/orca#23224; once a
|
|
@@ -418,7 +418,7 @@ const ON_A_CHOICE = /^( *[›❯] +)\d+\. /;
|
|
|
418
418
|
* Whether the rows of a rendered screen hold a question of the harness's own.
|
|
419
419
|
*
|
|
420
420
|
* Every one seen is a numbered list of choices with the harness's pointer on
|
|
421
|
-
* one (tech notes, section 1; ADR
|
|
421
|
+
* one (tech notes, section 1; ADR 0034). The same pointer starts the harness's
|
|
422
422
|
* input line and its echo of the user's past turns, and the input line is the
|
|
423
423
|
* lowest of them whenever it is on screen, so only the lowest pointer row is
|
|
424
424
|
* asked about: a question counts while it stands in the input line's place.
|
|
@@ -449,7 +449,8 @@ export function questionIn(rows) {
|
|
|
449
449
|
* `{}` for a tab with no harness in it (none in the book, none Orca lists, or
|
|
450
450
|
* the shell in front), `{ blocked }` for one with something on screen waiting
|
|
451
451
|
* to be answered, and `{ unsure }`, a sentence, for one the kit cannot tell
|
|
452
|
-
* about
|
|
452
|
+
* about, with `psUnread` where `ps` could not read the tab (#350). Orca
|
|
453
|
+
* refusing throws, for the caller to report.
|
|
453
454
|
*
|
|
454
455
|
* The one gate for every line the kit types into a running session: the mail
|
|
455
456
|
* nudge and the skills reload. A busy harness passes it, since both harnesses
|
|
@@ -497,7 +498,8 @@ export function tabToTypeInto(home, tabId, timeoutMs) {
|
|
|
497
498
|
const why = seen.front === undefined
|
|
498
499
|
? seen.unreadable
|
|
499
500
|
: `${seen.command} holds its terminal, and Orca names ${seen.agent ?? 'no agent'} in it`;
|
|
500
|
-
|
|
501
|
+
// Where `ps` could not read the tab, a reader that can may yet tell (#350).
|
|
502
|
+
return { unsure: `the kit could not tell whether a harness is running in it (${why}), so nothing was typed`, ...(seen.psUnread ? { psUnread: true } : {}) };
|
|
501
503
|
}
|
|
502
504
|
// Orca's reason does not cover every question: it called Codex's update
|
|
503
505
|
// offer idle, and a return typed into it took "Update now" (#329). So the
|
|
@@ -554,13 +556,13 @@ const psCli = () => process.env.OBK_PS || '/bin/ps';
|
|
|
554
556
|
*
|
|
555
557
|
* `ps` is asked first. Where it cannot read the tab, as inside Codex's
|
|
556
558
|
* sandbox, where it does not start at all, Orca's runtime is asked instead
|
|
557
|
-
* (#298, ADR
|
|
559
|
+
* (#298, ADR 0034).
|
|
558
560
|
*/
|
|
559
561
|
function frontOf(handle, ptyId, readMs) {
|
|
560
562
|
const read = frontByPs(ptyId, readMs);
|
|
561
563
|
if (read.unreadable === undefined) return read;
|
|
562
564
|
const asked = frontByOrca(handle);
|
|
563
|
-
return asked.unreadable === undefined ? asked : { unreadable: `${read.unreadable}, and ${asked.unreadable}` };
|
|
565
|
+
return { ...(asked.unreadable === undefined ? asked : { unreadable: `${read.unreadable}, and ${asked.unreadable}` }), psUnread: true };
|
|
564
566
|
}
|
|
565
567
|
|
|
566
568
|
/**
|
|
@@ -590,7 +592,7 @@ function frontByOrca(handle) {
|
|
|
590
592
|
* Who holds the terminal of the pane `ptyId`, as `ps` says it.
|
|
591
593
|
*
|
|
592
594
|
* Orca gives the pane's pid in `diagnostics memory` and nowhere else, and `ps`
|
|
593
|
-
* gives that pid's terminal's foreground process group (ADR
|
|
595
|
+
* gives that pid's terminal's foreground process group (ADR 0034).
|
|
594
596
|
* On macOS the pane is `login` with the shell as its child, so the shell is in
|
|
595
597
|
* front when the group is the pane's own or that of a child of a `login` pane.
|
|
596
598
|
* `diagnostics memory` is a diagnostics command and may change, so everything
|
package/src/temp.js
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
// Any long-lived session can make a temporary session of its own bot for a
|
|
6
6
|
// piece of work, and retire it when the work is done, without Bot Father, who
|
|
7
7
|
// stays the manager of long-lived sessions. A temporary session can make one
|
|
8
|
-
// of its own, one level deep and one at a time (#464, ADR 0033).
|
|
8
|
+
// of its own, one level deep and one at a time (#464, ADR 0033). It can be
|
|
9
|
+
// made in a role of its bot's `temp_roles`, which gives its settings, and
|
|
10
|
+
// whose cap limits how many are open (#465). A temporary session is an ordinary
|
|
9
11
|
// session in every other way: in bot.yaml, brought up with a tab and a mailbox,
|
|
10
12
|
// its conversations kept in the book. What makes it temporary is one field of
|
|
11
13
|
// its book entry, `temporary: { maker, made }`, and the book is the one place
|
|
@@ -27,8 +29,8 @@ import path from 'node:path';
|
|
|
27
29
|
import { setTimeout as pause } from 'node:timers/promises';
|
|
28
30
|
|
|
29
31
|
import { readBook, updateBook } from './book.js';
|
|
30
|
-
import { addSession, dropSession, NAME, readBot } from './bot.js';
|
|
31
|
-
import { harnessOf, isShortPrompt, ownCli, shellWord, startPrompt, workDirOf } from './launch.js';
|
|
32
|
+
import { addSession, dropSession, NAME, readBot, tempRoles } from './bot.js';
|
|
33
|
+
import { DEFAULT_APPROVAL, harnessOf, isShortPrompt, ownCli, shellWord, startPrompt, workDirOf } from './launch.js';
|
|
32
34
|
import { sessionInTab } from './message.js';
|
|
33
35
|
import { orca, screenRows, tabs } from './orca.js';
|
|
34
36
|
import { retireSession } from './retire.js';
|
|
@@ -39,45 +41,43 @@ const INHERITED = ['model', 'effort', 'context', 'approval'];
|
|
|
39
41
|
|
|
40
42
|
/**
|
|
41
43
|
* Make a temporary session of the caller's own bot and bring it up. `given`
|
|
42
|
-
* holds what the caller said: `name`, `prompt` or `prompt_file`,
|
|
43
|
-
* `harness` and the inherited settings
|
|
44
|
-
*
|
|
44
|
+
* holds what the caller said: `name`, `prompt` or `prompt_file`, any of
|
|
45
|
+
* `harness` and the inherited settings, and `role`, as `role` or
|
|
46
|
+
* `role:option`, to take the settings that role's option gives in its bot's
|
|
47
|
+
* `temp_roles` (#465). Returns `{ bot, session, maker, settings, chosen,
|
|
48
|
+
* role, up }`: `chosen` says where each setting came from, `role` is there
|
|
49
|
+
* when one was asked for, and `up` is what bringing it up answered.
|
|
45
50
|
*/
|
|
46
51
|
export async function makeTemp(bots, { tab, ...given }) {
|
|
47
52
|
const caller = callerIn(bots, tab, 'make');
|
|
48
53
|
// Checked here, before anything is written, and again under the book's lock
|
|
49
54
|
// below, where two makes at once cannot both pass it.
|
|
50
55
|
refuseNested(bots, caller, readBook(caller.home));
|
|
56
|
+
const bot = readBot(caller.home, caller.bot);
|
|
57
|
+
const role = given.role === undefined ? undefined : roleAsked(caller, bot, given.role);
|
|
58
|
+
const name = role === undefined ? given.name : roleName(caller, bot, role, given.name);
|
|
51
59
|
// The name is a folder under work/ and a file beside the bots folder, so it
|
|
52
60
|
// is held to the rule a bot's name is: one plain name, never a path.
|
|
53
|
-
if (!NAME.test(
|
|
54
|
-
throw new Error(`${
|
|
55
|
-
}
|
|
56
|
-
if (given.prompt === undefined && given.prompt_file === undefined) {
|
|
57
|
-
throw new Error(`temp make needs the task: --prompt <text> or --prompt-file <path>. Nothing was made.`);
|
|
61
|
+
if (!NAME.test(name)) {
|
|
62
|
+
throw new Error(`${name} cannot be a session's name: a name is lower-case letters, digits and single hyphens, such as review-250. Nothing was made.`);
|
|
58
63
|
}
|
|
59
64
|
if (given.prompt !== undefined && given.prompt_file !== undefined) {
|
|
60
65
|
throw new Error(`temp make was given the task twice, as --prompt and as --prompt-file, and a session is told its duty once. Give it one way. Nothing was made.`);
|
|
61
66
|
}
|
|
67
|
+
if (given.prompt === undefined && given.prompt_file === undefined && role?.prompt_file === undefined) {
|
|
68
|
+
throw new Error(`temp make needs the task: --prompt <text> or --prompt-file <path>${role === undefined ? '' : `, since the role ${role.name} has no prompt file`}. Nothing was made.`);
|
|
69
|
+
}
|
|
70
|
+
if (role !== undefined) refuseCap(caller, role, readBook(caller.home));
|
|
62
71
|
|
|
63
|
-
const bot = readBot(caller.home, caller.bot);
|
|
64
72
|
const maker = bot.sessions.find((session) => session.name === caller.session);
|
|
65
|
-
const settings =
|
|
66
|
-
|
|
67
|
-
if (harness !== undefined) settings.harness = harness;
|
|
68
|
-
// On another harness than its maker's, the model, the effort and the context
|
|
69
|
-
// are that harness's own, and only the approval carries over (#238, PRD 6.4).
|
|
70
|
-
const same = (harness ?? bot.harness) === harnessOf(maker ?? {}, bot.harness);
|
|
71
|
-
for (const field of INHERITED) {
|
|
72
|
-
const value = given[field] ?? (same || field === 'approval' ? maker?.[field] : undefined);
|
|
73
|
-
if (value !== undefined) settings[field] = value;
|
|
74
|
-
}
|
|
73
|
+
const { settings, chosen } = settingsFor(bot, maker, role?.option ?? {}, given);
|
|
74
|
+
settings.name = name;
|
|
75
75
|
if (given.extra_args !== undefined) settings.extra_args = given.extra_args;
|
|
76
76
|
if (given.prompt !== undefined) settings.prompt = given.prompt;
|
|
77
|
-
else settings.prompt_file = given.prompt_file;
|
|
78
|
-
settings.work_dir = `work/${
|
|
77
|
+
else settings.prompt_file = given.prompt_file ?? role.prompt_file;
|
|
78
|
+
settings.work_dir = `work/${name}`;
|
|
79
79
|
|
|
80
|
-
const written = bot.sessions.some((session) => session.name ===
|
|
80
|
+
const written = bot.sessions.some((session) => session.name === name) ? undefined : writePromptFirst(bots, caller, settings);
|
|
81
81
|
// bot.yaml and the book are written under the book's lock, so that a second
|
|
82
82
|
// make from the same maker waits, and then sees this one (#464). addSession
|
|
83
83
|
// refuses a name the bot already has, and a setting that will not work,
|
|
@@ -89,21 +89,23 @@ export async function makeTemp(bots, { tab, ...given }) {
|
|
|
89
89
|
await updateBook(caller.home, (book) => {
|
|
90
90
|
locked = true;
|
|
91
91
|
refuseNested(bots, caller, book);
|
|
92
|
+
if (role !== undefined) refuseCap(caller, role, book);
|
|
92
93
|
added = addSession(bots, caller.bot, settings);
|
|
93
|
-
|
|
94
|
+
const kind = role === undefined ? {} : { role: role.name, option: role.option.name };
|
|
95
|
+
book.sessions[name] = { ...book.sessions[name], temporary: { maker: caller.session, made, ...kind } };
|
|
94
96
|
});
|
|
95
97
|
} catch (error) {
|
|
96
98
|
// A refusal under the lock has written nothing but the prompt file.
|
|
97
99
|
const refused = locked && added === undefined;
|
|
98
|
-
const failed = `${caller.bot}/${
|
|
100
|
+
const failed = `${caller.bot}/${name} could not be recorded in the book as temporary (${error.message})`;
|
|
99
101
|
// A session in bot.yaml that the book does not call temporary would be a
|
|
100
102
|
// long-lived one to everything that reads it, its maker's retire included,
|
|
101
103
|
// so it is taken back off rather than left that way.
|
|
102
104
|
if (added !== undefined) {
|
|
103
105
|
try {
|
|
104
|
-
dropSession(bots, caller.bot,
|
|
106
|
+
dropSession(bots, caller.bot, name);
|
|
105
107
|
} catch (undo) {
|
|
106
|
-
throw new Error(`${failed}, and taking it back off bot.yaml failed too (${undo.message}). It is in bot.yaml as a session the book does not call temporary. Take it off with ${shellWord(ownCli())} retire --bots ${shellWord(bots)} --bot ${caller.bot} --session ${
|
|
108
|
+
throw new Error(`${failed}, and taking it back off bot.yaml failed too (${undo.message}). It is in bot.yaml as a session the book does not call temporary. Take it off with ${shellWord(ownCli())} retire --bots ${shellWord(bots)} --bot ${caller.bot} --session ${name}`);
|
|
107
109
|
}
|
|
108
110
|
}
|
|
109
111
|
// Off bot.yaml, nothing is made; a prompt file that stays is only a file,
|
|
@@ -120,12 +122,134 @@ export async function makeTemp(bots, { tab, ...given }) {
|
|
|
120
122
|
|
|
121
123
|
let up;
|
|
122
124
|
try {
|
|
123
|
-
up = await bringUp(bots, { bot: caller.bot, session:
|
|
125
|
+
up = await bringUp(bots, { bot: caller.bot, session: name });
|
|
124
126
|
} catch (error) {
|
|
125
127
|
const cli = shellWord(ownCli());
|
|
126
|
-
throw new Error(`${caller.bot}/${
|
|
128
|
+
throw new Error(`${caller.bot}/${name} is made, in bot.yaml and in the book as temporary with its maker ${caller.session}, and could not be brought up: ${error.message} Bring it up with ${cli} up --bots ${shellWord(bots)} --bot ${caller.bot} --session ${name} or retire it with ${cli} temp retire --bots ${shellWord(bots)} --name ${name}`);
|
|
129
|
+
}
|
|
130
|
+
return { bot: caller.bot, session: name, maker: caller.session, settings: added.session, chosen, ...(role === undefined ? {} : { role: roleAnswer(role) }), up };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The settings a temporary session is made with, and for each of the ones
|
|
135
|
+
* that can be inherited, what it got and where from: `chosen` (#465). A flag
|
|
136
|
+
* wins; then the role's option, on the option's harness, which is the maker's
|
|
137
|
+
* when the option names none; then the maker, on the maker's harness; and
|
|
138
|
+
* otherwise the harness's own default. On another harness than its maker's,
|
|
139
|
+
* only the approval carries over (#238, PRD 6.4).
|
|
140
|
+
*/
|
|
141
|
+
function settingsFor(bot, maker, option, given) {
|
|
142
|
+
const makers = harnessOf(maker ?? {}, bot.harness);
|
|
143
|
+
const settings = {};
|
|
144
|
+
const chosen = {};
|
|
145
|
+
const pick = (field, ...sources) => {
|
|
146
|
+
// YAML reads a setting written with no value as null, which gives nothing.
|
|
147
|
+
const found = sources.find(([, value]) => value !== undefined && value !== null);
|
|
148
|
+
if (found === undefined) {
|
|
149
|
+
chosen[field] = { from: 'harness default' };
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
chosen[field] = { value: found[1], from: found[0] };
|
|
153
|
+
if (found[0] !== 'maker' || field !== 'harness' || maker?.harness !== undefined) settings[field] = found[1];
|
|
154
|
+
};
|
|
155
|
+
pick('harness', ['flag', given.harness], ['role', option.harness], ['maker', makers]);
|
|
156
|
+
const harness = chosen.harness.value;
|
|
157
|
+
const fromRole = harness === (option.harness ?? makers);
|
|
158
|
+
const same = harness === makers;
|
|
159
|
+
for (const field of INHERITED) {
|
|
160
|
+
pick(
|
|
161
|
+
field,
|
|
162
|
+
['flag', given[field]],
|
|
163
|
+
['role', fromRole && field !== 'approval' ? option[field] : undefined],
|
|
164
|
+
['maker', same || field === 'approval' ? maker?.[field] : undefined],
|
|
165
|
+
// A session's approval is never left to the harness (ADR 0015).
|
|
166
|
+
['kit default', field === 'approval' ? DEFAULT_APPROVAL : undefined],
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
return { settings, chosen };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The role and option `asked` names, as `role` or `role:option`, from the
|
|
174
|
+
* bot's roles, with the option it takes; or a refusal that names what the bot
|
|
175
|
+
* has to choose from.
|
|
176
|
+
*/
|
|
177
|
+
function roleAsked(caller, bot, asked) {
|
|
178
|
+
const [name, optionName, ...more] = asked.split(':');
|
|
179
|
+
const roles = tempRoles(caller.home, bot);
|
|
180
|
+
if (roles.length === 0) {
|
|
181
|
+
throw new Error(`${caller.bot} has no roles for temporary sessions: its bot.yaml has no temp_roles. Make it without --role, or give the bot its roles first. Nothing was made.`);
|
|
127
182
|
}
|
|
128
|
-
|
|
183
|
+
const role = roles.find((one) => one.name === name);
|
|
184
|
+
if (role === undefined || more.length > 0) {
|
|
185
|
+
throw new Error(`${caller.bot} has no role ${asked}. Its roles are ${roles.map((one) => one.name).join(', ')}; ask for one as --role <role> or --role <role>:<option>. Nothing was made.`);
|
|
186
|
+
}
|
|
187
|
+
if (role.trouble.length > 0) {
|
|
188
|
+
throw new Error(`the role ${name} cannot be used as it is written: ${role.trouble.join(' ')} Nothing was made.`);
|
|
189
|
+
}
|
|
190
|
+
const option = optionName === undefined ? role.options[0] : role.options.find((one) => one.name === optionName);
|
|
191
|
+
if (option === undefined) {
|
|
192
|
+
throw new Error(`the role ${name} has no option ${optionName}. Its options are ${optionsLine(optionsOf(role))}. Nothing was made.`);
|
|
193
|
+
}
|
|
194
|
+
return { ...role, option };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The name of a session made in `role`: `--name` as given when it already
|
|
199
|
+
* begins with the role, or with the role put in front. A name that begins with
|
|
200
|
+
* another of the bot's roles would say the wrong one, and is refused.
|
|
201
|
+
*/
|
|
202
|
+
function roleName(caller, bot, role, given) {
|
|
203
|
+
if (given.startsWith(`${role.name}-`)) return given;
|
|
204
|
+
const other = tempRoles(caller.home, bot).find((one) => given.startsWith(`${one.name}-`));
|
|
205
|
+
if (other !== undefined) {
|
|
206
|
+
throw new Error(`${given} begins with ${other.name}-, the name of another of ${caller.bot}'s roles, and a session made in ${role.name} is named for ${role.name}. Give --name without it, such as --name ${given.slice(other.name.length + 1)}, to make ${role.name}-${given.slice(other.name.length + 1)}. Nothing was made.`);
|
|
207
|
+
}
|
|
208
|
+
return `${role.name}-${given}`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Refuse a make in a role whose cap of open sessions is reached, against `book` (#465). */
|
|
212
|
+
function refuseCap(caller, role, book) {
|
|
213
|
+
if (role.cap === undefined) return;
|
|
214
|
+
const open = Object.keys(book.sessions).filter((name) => book.sessions[name]?.temporary?.role === role.name);
|
|
215
|
+
if (open.length >= role.cap) {
|
|
216
|
+
throw new Error(`the role ${role.name} has a cap of ${role.cap} open temporary sessions, and ${caller.bot} has ${open.length} open: ${open.join(', ')}. Retire one whose work is done first. Nothing was made.`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** A role's options as an answer gives them: each one's name, what it is for, and whether it is the default. */
|
|
221
|
+
const optionsOf = (role) => role.options.map((option, at) => ({ name: option.name, ...(option.for === undefined ? {} : { for: option.for }), default: at === 0 }));
|
|
222
|
+
|
|
223
|
+
/** Options as `optionsOf` gives them, on one line. */
|
|
224
|
+
export const optionsLine = (options) => options
|
|
225
|
+
.map((option) => `${option.name}${option.default ? ' (the default)' : ''}${option.for === undefined ? '' : `, for ${option.for}`}`)
|
|
226
|
+
.join('; ');
|
|
227
|
+
|
|
228
|
+
/** A role as an answer gives it: the option used, and every option the role has. */
|
|
229
|
+
const roleAnswer = (role) => ({
|
|
230
|
+
name: role.name,
|
|
231
|
+
option: role.option.name,
|
|
232
|
+
...(role.option.for === undefined ? {} : { for: role.option.for }),
|
|
233
|
+
options: optionsOf(role),
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The roles of the caller's bot, each with its cap, its open temporary
|
|
238
|
+
* sessions, its prompt file and its options, the first the default (#465).
|
|
239
|
+
* Returns `{ bot, roles }`.
|
|
240
|
+
*/
|
|
241
|
+
export function listRoles(bots, { tab }) {
|
|
242
|
+
const caller = callerIn(bots, tab, 'roles', 'list the roles');
|
|
243
|
+
const book = readBook(caller.home);
|
|
244
|
+
const roles = tempRoles(caller.home, readBot(caller.home, caller.bot)).map((role) => ({
|
|
245
|
+
name: role.name,
|
|
246
|
+
...(role.cap === undefined ? {} : { cap: role.cap }),
|
|
247
|
+
open: Object.keys(book.sessions).filter((name) => book.sessions[name]?.temporary?.role === role.name),
|
|
248
|
+
...(role.prompt_file === undefined ? {} : { prompt_file: role.prompt_file }),
|
|
249
|
+
options: role.options.map((option, at) => ({ ...option, default: at === 0 })),
|
|
250
|
+
...(role.trouble.length === 0 ? {} : { trouble: role.trouble }),
|
|
251
|
+
}));
|
|
252
|
+
return { bot: caller.bot, roles };
|
|
129
253
|
}
|
|
130
254
|
|
|
131
255
|
/**
|