@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assuredloop/orca-bot-kit",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
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,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 = { 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
+ };
658
707
  const retire = `${shellWord(ownCli())} temp retire --bots ${shellWord(bots)} --name ${made.session}`;
659
708
  return {
660
709
  answer,
661
- 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
+ ],
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 sentHook = { type: 'command', command: sentCommand(bots, bot), timeout: TIMEOUT };
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 send hook is judged on its own: the session hook may be right while
128
- // the warning after a native message is missing (ADR 0032).
129
- if (harness === 'claude' && isDeepStrictEqual(settings, wanted)) {
130
- const sentRunning = sentProgramIn(settings);
131
- // A send hook that runs a kit no longer there is as quiet as a missing
132
- // one: `|| true` hides it, and no warning is ever given (#451 review).
133
- if (sentRunning !== undefined && sentRunning !== BARE && !existsSync(sentRunning)) {
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 PostToolUse hook for SendMessage, but it runs ${sentRunning}, which is not there any more, so a session is never warned when its native message goes outside this bots folder (ADR 0032). obk up puts back one that runs the kit you have.`,
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 sentProgram = sentRunning ?? program;
140
- const withSent = { ...settings, hooks: withSentHook(settings.hooks, { type: 'command', command: sentCommand(bots, bot, sentProgram), timeout: TIMEOUT }) };
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 PostToolUse hook for SendMessage, which warns a session when its native message went outside this bots folder (ADR 0032). obk up puts it back.`,
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
- * The hooks with the kit's send hook in a `PostToolUse` group matched to
220
- * `SendMessage`, and everything else as the user left it, as `withKitHook`
221
- * keeps the session hook: one entry of the kit's, written in place where it
222
- * already sits in such a group, taken out of anywhere else, and added in a
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 withSentHook(hooks, mine) {
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 (!isSentHook(hook)) return [hook];
233
- if (event !== SENT_EVENT || group.matcher !== SENT_MATCHER || placed) return [];
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, [SENT_EVENT]: [...(events[SENT_EVENT] ?? []), { matcher: SENT_MATCHER, hooks: [mine] }] };
282
+ return { ...events, [wantedEvent]: [...(events[wantedEvent] ?? []), { matcher, hooks: [mine] }] };
243
283
  }
244
284
 
245
-
246
- /** Whether a hook entry is the kit's send hook, run by a kit, for any bots folder and bot. */
247
- const isSentHook = (hook) => sentProgramOf(hook?.command) !== undefined;
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 send hook in these settings runs, or undefined when there is none. */
257
- function sentProgramIn(settings) {
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 = sentProgramOf(hook?.command);
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 0031).
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
@@ -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 0031): `project.update`, which makes Orca's
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 0031). Orca's CLI
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 0031). The caller prints
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 0031). None of that is
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 0031).
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 0031). The same pointer starts the harness's
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. Orca refusing throws, for the caller to report.
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
- return { unsure: `the kit could not tell whether a harness is running in it (${why}), so nothing was typed` };
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 0031).
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 0031).
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). A temporary session is an ordinary
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`, and any of
43
- * `harness` and the inherited settings. Returns `{ bot, session, maker,
44
- * settings, up }`, where `up` is what bringing it up answered.
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(given.name)) {
54
- throw new Error(`${given.name} cannot be a session's name: a name is lower-case letters, digits and single hyphens, such as review-250. Nothing was made.`);
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 = { name: given.name };
66
- const harness = given.harness ?? maker?.harness;
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/${given.name}`;
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 === given.name) ? undefined : writePromptFirst(bots, caller, settings);
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
- book.sessions[given.name] = { ...book.sessions[given.name], temporary: { maker: caller.session, made } };
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}/${given.name} could not be recorded in the book as temporary (${error.message})`;
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, given.name);
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 ${given.name}`);
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: given.name });
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}/${given.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 ${given.name} or retire it with ${cli} temp retire --bots ${shellWord(bots)} --name ${given.name}`);
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
- return { bot: caller.bot, session: given.name, maker: caller.session, settings: added.session, up };
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
  /**