@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assuredloop/orca-bot-kit",
3
- "version": "0.20.0",
3
+ "version": "0.22.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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 long-lived session may make a
8
- temporary session of its own bot, without asking Bot Father:
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. Its maker retires it.
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. Or end
94
- a bot: close its tabs, remove its Orca project and
95
- move its folder to retired/. It will not retire a
96
- bot whose Orca project holds a tab your book does
97
- not name, and moves the folder only once Orca no
98
- longer lists the project.
99
- obk temp make --bots <path> --name <session>
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 long-lived session's own tab: make a
104
- temporary session of that session's bot for a piece
105
- of work, and bring it up. It takes your harness,
106
- model, effort, context and approval unless you say
107
- otherwise; on another harness than yours it takes
108
- only your approval. It works in work/<session>, and
109
- has the task as its start prompt. The book records
110
- it as temporary, made by you.
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 one command a harness runs rather than a person: the kit's hook. */
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 = { bots, bot: made.bot, session: made.session, maker: made.maker, settings: made.settings, created: [], completed: [], rules, skills, permissions, tabs, paused, projects };
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: 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}`),
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 wanted = { ...settings, hooks: withKitHook(settings.hooks, file, mine) };
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';