@assuredloop/orca-bot-kit 0.20.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 +14 -0
- package/package.json +1 -1
- package/rules/temporary.md +7 -8
- package/src/book.js +2 -1
- package/src/bot.js +78 -1
- package/src/cli.js +134 -21
- package/src/health.js +12 -1
- package/src/hooks.js +127 -1
- 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/retire.js +45 -13
- package/src/sent.js +111 -0
- package/src/temp.js +196 -48
package/README.md
CHANGED
|
@@ -344,6 +344,20 @@ settings ([ADR 0022](docs/adr/0022-kit-hooks-live-in-the-bot-folder.md)). Your o
|
|
|
344
344
|
settings in those files are kept. Codex asks you to trust a hooks file the first
|
|
345
345
|
time it sees one; answer its question in the tab.
|
|
346
346
|
|
|
347
|
+
A Claude bot gets one more hook there. Claude Code's own messaging reaches every
|
|
348
|
+
Claude session on the machine, so after a session sends a native message to an
|
|
349
|
+
address that is none of your bots folder's sessions, the hook tells it so, and
|
|
350
|
+
names the address `obk message to` would have given. The message goes either
|
|
351
|
+
way ([ADR 0032](docs/adr/0032-a-native-message-outside-the-fleet-is-warned-about.md)).
|
|
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
|
+
|
|
347
361
|
So: kill a tab, or reboot, and `obk up` brings the session back with the
|
|
348
362
|
conversation it was having, rather than starting a new one. Clear a session —
|
|
349
363
|
`/clear` on Claude Code, `/new` on Codex — and the kit writes down the new id,
|
package/package.json
CHANGED
package/rules/temporary.md
CHANGED
|
@@ -4,18 +4,17 @@ title: Temporary sessions of your own
|
|
|
4
4
|
applies: all
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
For a piece of work that wants a helper, a
|
|
8
|
-
|
|
7
|
+
For a piece of work that wants a helper, a session may make a temporary
|
|
8
|
+
session of its own bot, without asking Bot Father:
|
|
9
9
|
`"${OBK_CLI:-obk}" temp make --bots <bots> --name <name> --prompt <the task>`,
|
|
10
10
|
run in your own tab. It takes your settings unless you say otherwise, and gets
|
|
11
|
-
a work dir, a tab and a mailbox.
|
|
11
|
+
a work dir, a tab and a mailbox. A temporary session may make one of its own,
|
|
12
|
+
one at a time; one made by a temporary session makes none.
|
|
12
13
|
|
|
13
14
|
A new tab can stop on a first-run screen. Answer it yourself, from the table
|
|
14
15
|
the command's output points to.
|
|
15
16
|
|
|
16
|
-
Retire it when its work is done:
|
|
17
|
+
Retire it when its work is done, and what it made goes with it:
|
|
17
18
|
`"${OBK_CLI:-obk}" temp retire --bots <bots> --name <name>`. Before you make
|
|
18
|
-
another, first retire any of yours whose work is done.
|
|
19
|
-
|
|
20
|
-
You manage only the temporary sessions you made; a temporary session makes
|
|
21
|
-
none of its own. Every other session is Bot Father's to make, change or retire.
|
|
19
|
+
another, first retire any of yours whose work is done. You manage only the
|
|
20
|
+
temporary sessions you made; every other session is Bot Father's.
|
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,18 +17,19 @@ 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';
|
|
24
24
|
import { recordSession, SHELL_ENV, TAB_ENV } from './record.js';
|
|
25
25
|
import { restartSessions } from './restart.js';
|
|
26
26
|
import { retireBot, retireSession } from './retire.js';
|
|
27
|
+
import { sentWarning } from './sent.js';
|
|
27
28
|
import { readRoster } from './roster.js';
|
|
28
29
|
import { buildAgents, buildRules, CODEX_CAP } from './rules.js';
|
|
29
30
|
import { addSkill, buildSkills, linkSkills, removeSkill } from './skills.js';
|
|
30
31
|
import { addSource, fetchSources } from './sources.js';
|
|
31
|
-
import { makeTemp, retireTemp, trustHooks } from './temp.js';
|
|
32
|
+
import { listRoles, makeTemp, optionsLine, retireTemp, trustHooks } from './temp.js';
|
|
32
33
|
import { readUsage } from './usage.js';
|
|
33
34
|
import { BOT_FATHER, bringUp, ownMailbox } from './up.js';
|
|
34
35
|
|
|
@@ -90,28 +91,47 @@ Usage:
|
|
|
90
91
|
session with the conversation it was having.
|
|
91
92
|
obk retire --bots <path> --bot <bot> [--session <name>]
|
|
92
93
|
End a session: close its tab and take it off the
|
|
93
|
-
bot, keeping its conversations in the book
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
94
|
+
bot, keeping its conversations in the book, and
|
|
95
|
+
first do the same for the temporary sessions it
|
|
96
|
+
made, and theirs. Or end a bot: close its tabs,
|
|
97
|
+
remove its Orca project and move its folder to
|
|
98
|
+
retired/. It will not retire a bot whose Orca
|
|
99
|
+
project holds a tab your book does not name, and
|
|
100
|
+
moves the folder only once Orca no longer lists
|
|
101
|
+
the project.
|
|
102
|
+
obk temp make --bots <path> --name <session> [--role <role>[:<option>]]
|
|
100
103
|
(--prompt <text> | --prompt-file <path>) [--harness claude|codex]
|
|
101
104
|
[--model <m>] [--effort <e>] [--context <c>]
|
|
102
105
|
[--approval ${APPROVALS.join('|')}] [--extra-arg=<arg>]...
|
|
103
|
-
Run in a
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
106
|
+
Run in a session's own tab: make a temporary
|
|
107
|
+
session of that session's bot for a piece of work,
|
|
108
|
+
and bring it up. A temporary session makes one at
|
|
109
|
+
a time, and one it made makes none. It takes your
|
|
110
|
+
harness, model, effort, context and approval unless
|
|
111
|
+
you say otherwise; on another harness than yours
|
|
112
|
+
it takes only your approval. It works in
|
|
113
|
+
work/<session>, and has the task as its start
|
|
114
|
+
prompt. The book records it as temporary, made by
|
|
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.
|
|
111
129
|
obk temp retire --bots <path> --name <session>
|
|
112
130
|
Run in the maker's own tab: retire a temporary
|
|
113
131
|
session it made, as obk retire does. It refuses a
|
|
114
132
|
long-lived session, or one another session made.
|
|
133
|
+
Like obk retire, it first retires the temporary
|
|
134
|
+
sessions that session made.
|
|
115
135
|
obk temp trust-hooks --bots <path> --name <session>
|
|
116
136
|
Run in the maker's own tab: answer the hooks review
|
|
117
137
|
of a Codex temporary session it made with "Trust
|
|
@@ -230,6 +250,17 @@ Usage:
|
|
|
230
250
|
For the kit's own hook, not for typing: it reads
|
|
231
251
|
what the harness says about a session starting on
|
|
232
252
|
standard input and writes it into the book.
|
|
253
|
+
obk session sent --bots <path> --bot <bot>
|
|
254
|
+
For the kit's own hook, not for typing: after a
|
|
255
|
+
Claude session's native message, it warns the
|
|
256
|
+
session when the address was none of this bots
|
|
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.
|
|
233
264
|
obk session mailbox --bots <path> --bot <bot> --session <name>
|
|
234
265
|
For the kit's own launch line, not for typing: run
|
|
235
266
|
in the session's own tab, it gives the session its
|
|
@@ -269,8 +300,11 @@ const COMMANDS = {
|
|
|
269
300
|
'message send': ['bots', 'to', 'subject'],
|
|
270
301
|
'message check': ['bots'],
|
|
271
302
|
'session record': ['bots', 'bot'],
|
|
303
|
+
'session sent': ['bots', 'bot'],
|
|
304
|
+
'session nudge': ['bots', 'bot'],
|
|
272
305
|
'session mailbox': ['bots', 'bot', 'session'],
|
|
273
306
|
'temp make': ['bots', 'name'],
|
|
307
|
+
'temp roles': ['bots'],
|
|
274
308
|
'temp retire': ['bots', 'name'],
|
|
275
309
|
'temp trust-hooks': ['bots', 'name'],
|
|
276
310
|
};
|
|
@@ -293,6 +327,7 @@ const NEEDED = {
|
|
|
293
327
|
to: '--to <bot>/<session>: which session to write to',
|
|
294
328
|
from: '--from <bot>/<session>: which session is writing',
|
|
295
329
|
subject: '--subject <text>: what the message is about',
|
|
330
|
+
role: '--role <role>[:<option>]: which role of the bot\'s temp_roles',
|
|
296
331
|
};
|
|
297
332
|
|
|
298
333
|
/** The flags that name something. A name that is empty names nothing. */
|
|
@@ -347,6 +382,7 @@ async function run(argv) {
|
|
|
347
382
|
...Object.fromEntries(SETTINGS.map(([flag]) => [flag, { type: 'string' }])),
|
|
348
383
|
'extra-arg': { type: 'string', multiple: true },
|
|
349
384
|
'run-on': { type: 'string' },
|
|
385
|
+
role: { type: 'string' },
|
|
350
386
|
json: { type: 'boolean' },
|
|
351
387
|
help: { type: 'boolean', short: 'h' },
|
|
352
388
|
version: { type: 'boolean' },
|
|
@@ -394,6 +430,11 @@ async function run(argv) {
|
|
|
394
430
|
throw new Error(`--harness is ${HARNESSES.join(' or ')}, and got: ${values.harness}`);
|
|
395
431
|
}
|
|
396
432
|
|
|
433
|
+
// The send hook is quiet whatever it is given, a bots folder that is not
|
|
434
|
+
// there included, so it goes before anything that can complain (ADR 0032).
|
|
435
|
+
if (command === SENT) return sent(path.resolve(values.bots));
|
|
436
|
+
if (command === NUDGE) return nudgeLeft();
|
|
437
|
+
|
|
397
438
|
// One fleet, one identity, whatever spelling of its path was given (#164).
|
|
398
439
|
const bots = command === 'init' ? path.resolve(values.bots) : sameFleet(path.resolve(values.bots));
|
|
399
440
|
if (command === RECORD) return record(bots, values.bot);
|
|
@@ -457,8 +498,48 @@ const ANSWER_IT = [
|
|
|
457
498
|
' Anything you do not recognise gets no keypress: take it to the user.',
|
|
458
499
|
];
|
|
459
500
|
|
|
460
|
-
/** The
|
|
501
|
+
/** The commands a harness runs rather than a person: the kit's hooks. */
|
|
461
502
|
const RECORD = 'session record';
|
|
503
|
+
const SENT = 'session sent';
|
|
504
|
+
const NUDGE = 'session nudge';
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* What the kit's send hook says after a native message: a warning the session
|
|
508
|
+
* reads, as Claude Code's `additionalContext`, or nothing. Never a decision,
|
|
509
|
+
* and never anything but exit 0 (ADR 0032).
|
|
510
|
+
*/
|
|
511
|
+
function sent(bots) {
|
|
512
|
+
try {
|
|
513
|
+
const warning = sentWarning(bots, JSON.parse(readFileSync(0, 'utf8')));
|
|
514
|
+
if (warning !== undefined) {
|
|
515
|
+
process.stdout.write(`${JSON.stringify({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: warning } })}\n`);
|
|
516
|
+
}
|
|
517
|
+
} catch {
|
|
518
|
+
// Nothing: a hook does not disturb the session it runs in.
|
|
519
|
+
}
|
|
520
|
+
return 0;
|
|
521
|
+
}
|
|
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
|
+
}
|
|
462
543
|
|
|
463
544
|
/**
|
|
464
545
|
* What the kit's hook does with what the harness told it, and what it answers.
|
|
@@ -571,6 +652,7 @@ const commands = {
|
|
|
571
652
|
return {
|
|
572
653
|
answer: { bots, ...retired },
|
|
573
654
|
lines: [
|
|
655
|
+
...withLines(retired.retiredWith, retired.session),
|
|
574
656
|
...closedLines(retired.closed),
|
|
575
657
|
`retired ${retired.bot} ${retired.session}: off ${path.join('bots', retired.bot, 'bot.yaml')}, and its conversations kept in the book under retired`,
|
|
576
658
|
...leftLines(retired.promptsLeft),
|
|
@@ -611,21 +693,41 @@ const commands = {
|
|
|
611
693
|
const tab = callerTab(bots, true);
|
|
612
694
|
refuseWhenOrcaIsDown();
|
|
613
695
|
const given = { name: values.name };
|
|
614
|
-
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']]) {
|
|
615
697
|
if (values[flag] !== undefined) given[key] = values[flag];
|
|
616
698
|
}
|
|
617
699
|
if (values.context !== undefined) given.context = asNumberOrText(values.context);
|
|
618
700
|
if (values['extra-arg'] !== undefined) given.extra_args = values['extra-arg'];
|
|
619
701
|
const made = await makeTemp(bots, { tab, ...given });
|
|
620
702
|
const { tabs, rules, skills, permissions, paused, projects } = made.up;
|
|
621
|
-
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
|
+
};
|
|
622
707
|
const retire = `${shellWord(ownCli())} temp retire --bots ${shellWord(bots)} --name ${made.session}`;
|
|
623
708
|
return {
|
|
624
709
|
answer,
|
|
625
|
-
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
|
+
],
|
|
626
715
|
};
|
|
627
716
|
},
|
|
628
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
|
+
|
|
629
731
|
async 'temp retire'(bots, values) {
|
|
630
732
|
const tab = callerTab(bots, true);
|
|
631
733
|
refuseWhenOrcaIsDown();
|
|
@@ -633,6 +735,7 @@ const commands = {
|
|
|
633
735
|
return {
|
|
634
736
|
answer: { bots, ...retired },
|
|
635
737
|
lines: [
|
|
738
|
+
...withLines(retired.retiredWith, retired.session),
|
|
636
739
|
...closedLines(retired.closed),
|
|
637
740
|
`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`,
|
|
638
741
|
...leftLines(retired.promptsLeft),
|
|
@@ -1070,6 +1173,9 @@ function nudgeLine(answer, where) {
|
|
|
1070
1173
|
if (answer.blocked !== undefined) {
|
|
1071
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.`;
|
|
1072
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
|
+
}
|
|
1073
1179
|
if (answer.nudgeTrouble !== undefined) {
|
|
1074
1180
|
return ` it is queued, and its tab could not be told to look: ${answer.nudgeTrouble}`;
|
|
1075
1181
|
}
|
|
@@ -1106,6 +1212,13 @@ function settingsOf(values) {
|
|
|
1106
1212
|
/** The tabs a command closed, one line each. */
|
|
1107
1213
|
const closedLines = (closed) => closed.map((tab) => `closed ${tab.bot} ${tab.name} tab ${tab.tabId} terminal ${tab.terminal}`);
|
|
1108
1214
|
|
|
1215
|
+
/** The temporary sessions a retire took along with the one it named, each as it went (#464). */
|
|
1216
|
+
const withLines = (retiredWith, session) => retiredWith.flatMap((gone) => [
|
|
1217
|
+
...closedLines(gone.closed),
|
|
1218
|
+
`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`,
|
|
1219
|
+
...leftLines(gone.promptsLeft),
|
|
1220
|
+
]);
|
|
1221
|
+
|
|
1109
1222
|
/** What a retire could not remove, and how to: nothing reads these files now (#393). */
|
|
1110
1223
|
const leftLines = (left = []) => left.flatMap(({ file, reason }) => [
|
|
1111
1224
|
`left ${file}: it could not be removed (${reason}). Nothing reads it now.`,
|
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
|
@@ -42,6 +42,33 @@ const TIMEOUT = 10;
|
|
|
42
42
|
export const hookCommand = (bots, bot, cli = ownCli()) =>
|
|
43
43
|
`${shellWord(cli)} session record --bots ${shellWord(bots)} --bot ${shellWord(bot)} 2>/dev/null || true`;
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Claude Code's second kit hook: after a native `SendMessage`, a warning when
|
|
47
|
+
* it went outside the bots folder (ADR 0032). Claude Code only, since Codex has
|
|
48
|
+
* no native messaging.
|
|
49
|
+
*/
|
|
50
|
+
const SENT_EVENT = 'PostToolUse';
|
|
51
|
+
const SENT_MATCHER = 'SendMessage';
|
|
52
|
+
|
|
53
|
+
/** What the send hook runs, by the kit's own path, as `hookCommand` does. */
|
|
54
|
+
export const sentCommand = (bots, bot, cli = ownCli()) =>
|
|
55
|
+
`${shellWord(cli)} session sent --bots ${shellWord(bots)} --bot ${shellWord(bot)} 2>/dev/null || true`;
|
|
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
|
+
|
|
45
72
|
/**
|
|
46
73
|
* Make sure the bot at `home` has the kit's SessionStart hook for `harness`.
|
|
47
74
|
* Returns the file it wrote, relative to the bot home, or undefined when the
|
|
@@ -63,7 +90,8 @@ export function installHook(home, harness, { bots, bot }) {
|
|
|
63
90
|
// path is corrected rather than doubled, an entry under an event the kit no
|
|
64
91
|
// longer asks about is taken out, and a hook of the user's beside the kit's
|
|
65
92
|
// stays where they put it.
|
|
66
|
-
const
|
|
93
|
+
const started = withKitHook(settings.hooks, file, mine);
|
|
94
|
+
const wanted = { ...settings, hooks: withToolHook(started, toolHookOf(harness, bots, bot)) };
|
|
67
95
|
|
|
68
96
|
// Compared as documents, not as text: how the user laid their file out is
|
|
69
97
|
// theirs, and a run that changes nothing writes nothing.
|
|
@@ -110,6 +138,28 @@ export function hookTrouble(home, harness, { bots, bot }) {
|
|
|
110
138
|
}
|
|
111
139
|
const program = running ?? ownCli();
|
|
112
140
|
wanted = { ...settings, hooks: withKitHook(settings.hooks, file, { type: 'command', command: hookCommand(bots, bot, program), timeout: TIMEOUT }) };
|
|
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)) {
|
|
150
|
+
return {
|
|
151
|
+
where: file,
|
|
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.`,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
const withTool = { ...settings, hooks: withToolHook(settings.hooks, toolHookOf(harness, bots, bot, toolRunning ?? program)) };
|
|
156
|
+
if (!isDeepStrictEqual(settings, withTool)) {
|
|
157
|
+
return {
|
|
158
|
+
where: file,
|
|
159
|
+
says: `${file} does not hold the kit's ${tool.event} hook for ${tool.matcher}, without which ${tool.missing}. obk up puts it back.`,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
}
|
|
113
163
|
} catch (error) {
|
|
114
164
|
return { where: file, says: `${error.message} ${stale}` };
|
|
115
165
|
}
|
|
@@ -180,6 +230,80 @@ function withKitHook(hooks, file, mine) {
|
|
|
180
230
|
return { ...events, [EVENT]: [...(events[EVENT] ?? []), { hooks: [mine] }] };
|
|
181
231
|
}
|
|
182
232
|
|
|
233
|
+
/**
|
|
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.
|
|
238
|
+
*/
|
|
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 }) {
|
|
266
|
+
let placed = false;
|
|
267
|
+
const events = Object.fromEntries(Object.entries(hooks).flatMap(([event, groups]) => {
|
|
268
|
+
if (!Array.isArray(groups)) return [[event, groups]];
|
|
269
|
+
const kept = groups.flatMap((group) => {
|
|
270
|
+
if (!Array.isArray(group?.hooks)) return [group];
|
|
271
|
+
const entries = group.hooks.flatMap((hook) => {
|
|
272
|
+
if (programOf(hook?.command, pattern) === undefined) return [hook];
|
|
273
|
+
if (event !== wantedEvent || group.matcher !== matcher || placed) return [];
|
|
274
|
+
placed = true;
|
|
275
|
+
return [mine];
|
|
276
|
+
});
|
|
277
|
+
return entries.length === 0 ? [] : [{ ...group, hooks: entries }];
|
|
278
|
+
});
|
|
279
|
+
return kept.length === 0 ? [] : [[event, kept]];
|
|
280
|
+
}));
|
|
281
|
+
if (placed) return events;
|
|
282
|
+
return { ...events, [wantedEvent]: [...(events[wantedEvent] ?? []), { matcher, hooks: [mine] }] };
|
|
283
|
+
}
|
|
284
|
+
|
|
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;
|
|
288
|
+
if (found === null) return undefined;
|
|
289
|
+
const program = unquoted(found[1]);
|
|
290
|
+
return isKitCli(program) ? program : undefined;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** What the kit's hook of this pattern in these settings runs, or undefined when there is none. */
|
|
294
|
+
function programIn(settings, pattern) {
|
|
295
|
+
for (const groups of Object.values(settings.hooks ?? {})) {
|
|
296
|
+
if (!Array.isArray(groups)) continue;
|
|
297
|
+
for (const group of groups) {
|
|
298
|
+
for (const hook of Array.isArray(group?.hooks) ? group.hooks : []) {
|
|
299
|
+
const program = programOf(hook?.command, pattern);
|
|
300
|
+
if (program !== undefined) return program;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return undefined;
|
|
305
|
+
}
|
|
306
|
+
|
|
183
307
|
/**
|
|
184
308
|
* The kit's own hook entry, wherever it sits and whatever sits beside it: a
|
|
185
309
|
* command of exactly the shape `hookCommand` writes, for any bots folder and
|
|
@@ -194,6 +318,8 @@ const isKitHook = (hook) => kitProgramOf(hook?.command) !== undefined;
|
|
|
194
318
|
/** One word as `shellWord` writes it: bare, or single-quoted with `'\''` inside. */
|
|
195
319
|
const WORD = String.raw`(?:[A-Za-z0-9,._+:@%/=-]+|'(?:[^']|'\\'')*')`;
|
|
196
320
|
const KIT_HOOK = new RegExp(String.raw`^(${WORD}) session record --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
|
|
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$`);
|
|
197
323
|
|
|
198
324
|
/** The program a bot made before the kit named itself by path runs. */
|
|
199
325
|
const BARE = 'obk';
|