@assuredloop/orca-bot-kit 0.21.0 → 0.23.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/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
@@ -377,14 +377,14 @@ export const QUESTION_ON_SCREEN = 'question-on-screen';
377
377
 
378
378
  /**
379
379
  * What the tab `handle` renders: `{ question }`, true when it shows a question
380
- * of its harness's own, or `{ screenUnreadable: <why> }`. Orca refusing is an
380
+ * of its harness's own, with the `rows` it read, or `{ screenUnreadable: <why> }`. Orca refusing is an
381
381
  * answer that cannot be read, not an error, and so is anything that is not the
382
382
  * rendered screen: accumulated output comes back in fragments (tech notes,
383
383
  * section 1).
384
384
  */
385
385
  function screenOf(handle, readMs) {
386
386
  const read = screenRows(handle, readMs);
387
- return read.rows === undefined ? { screenUnreadable: read.unreadable } : { question: questionIn(read.rows) };
387
+ return read.rows === undefined ? { screenUnreadable: read.unreadable } : { question: questionIn(read.rows), rows: read.rows };
388
388
  }
389
389
 
390
390
  /**
@@ -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.
@@ -445,11 +445,14 @@ export function questionIn(rows) {
445
445
  * Whether the kit may type a line into `tabId`, the tab the book holds for a
446
446
  * session in the Orca project at `home`, and the handle to type into when it
447
447
  * may: `{ handle, agent }`, with the agent Orca names there. Otherwise nothing
448
- * is typed, and the answer says why:
448
+ * is typed, and the answer says why. `idle` says whether Orca's `tui-idle` wait
449
+ * answered ok, and `rows` is the screen the gate read, for a caller that must
450
+ * not type into a busy harness (#391):
449
451
  * `{}` for a tab with no harness in it (none in the book, none Orca lists, or
450
452
  * the shell in front), `{ blocked }` for one with something on screen waiting
451
453
  * to be answered, and `{ unsure }`, a sentence, for one the kit cannot tell
452
- * about. Orca refusing throws, for the caller to report.
454
+ * about, with `psUnread` where `ps` could not read the tab (#350). Orca
455
+ * refusing throws, for the caller to report.
453
456
  *
454
457
  * The one gate for every line the kit types into a running session: the mail
455
458
  * nudge and the skills reload. A busy harness passes it, since both harnesses
@@ -497,7 +500,8 @@ export function tabToTypeInto(home, tabId, timeoutMs) {
497
500
  const why = seen.front === undefined
498
501
  ? seen.unreadable
499
502
  : `${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` };
503
+ // Where `ps` could not read the tab, a reader that can may yet tell (#350).
504
+ return { unsure: `the kit could not tell whether a harness is running in it (${why}), so nothing was typed`, ...(seen.psUnread ? { psUnread: true } : {}) };
501
505
  }
502
506
  // Orca's reason does not cover every question: it called Codex's update
503
507
  // offer idle, and a return typed into it took "Update now" (#329). So the
@@ -506,7 +510,7 @@ export function tabToTypeInto(home, tabId, timeoutMs) {
506
510
  if (seen.question === undefined) {
507
511
  return { unsure: `the kit could not tell whether a question is waiting on its screen (${seen.screenUnreadable}), so nothing was typed` };
508
512
  }
509
- return { handle: live.handle, agent: seen.agent };
513
+ return { handle: live.handle, agent: seen.agent, idle: seen.answered, rows: seen.rows };
510
514
  }
511
515
 
512
516
  /**
@@ -554,13 +558,13 @@ const psCli = () => process.env.OBK_PS || '/bin/ps';
554
558
  *
555
559
  * `ps` is asked first. Where it cannot read the tab, as inside Codex's
556
560
  * sandbox, where it does not start at all, Orca's runtime is asked instead
557
- * (#298, ADR 0031).
561
+ * (#298, ADR 0034).
558
562
  */
559
563
  function frontOf(handle, ptyId, readMs) {
560
564
  const read = frontByPs(ptyId, readMs);
561
565
  if (read.unreadable === undefined) return read;
562
566
  const asked = frontByOrca(handle);
563
- return asked.unreadable === undefined ? asked : { unreadable: `${read.unreadable}, and ${asked.unreadable}` };
567
+ return { ...(asked.unreadable === undefined ? asked : { unreadable: `${read.unreadable}, and ${asked.unreadable}` }), psUnread: true };
564
568
  }
565
569
 
566
570
  /**
@@ -590,7 +594,7 @@ function frontByOrca(handle) {
590
594
  * Who holds the terminal of the pane `ptyId`, as `ps` says it.
591
595
  *
592
596
  * 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).
597
+ * gives that pid's terminal's foreground process group (ADR 0034).
594
598
  * On macOS the pane is `login` with the shell as its child, so the shell is in
595
599
  * front when the group is the pane's own or that of a child of a `login` pane.
596
600
  * `diagnostics memory` is a diagnostics command and may change, so everything
package/src/record.js CHANGED
@@ -80,11 +80,10 @@ export async function recordSession(bots, name, said, tabId, shellPid) {
80
80
  // this bot's folder, which every session of the bot shares — so it can say
81
81
  // that a conversation nobody claims exists, and never whose it is.
82
82
  const unclaimed = unclaimedFor(readBook(home), home, bot, tabId, id);
83
- // A clear on Claude Code reads the bot's instructions again, as a start does
84
- // (tech notes, section 2), so the book notes which ones it read. Codex has no
85
- // word for its clear, and whether its new conversation reads them again is
86
- // not established, so nothing is noted for it.
87
- const rules = said.source === CLEARED ? rulesStamp(home) : undefined;
83
+ // A clear reads the bot's instructions again, as a start does, so the book
84
+ // notes which ones it read: Claude Code's `/clear` (tech notes, section 2),
85
+ // and Codex's `/new`, seen here as a new id (#391).
86
+ const stamp = rulesStamp(home);
88
87
 
89
88
  let cleared = false;
90
89
  let told;
@@ -106,6 +105,7 @@ export async function recordSession(bots, name, said, tabId, shellPid) {
106
105
  const uncertain = was.session === undefined && unclaimed.length > 0;
107
106
  told = session;
108
107
 
108
+ const rules = cleared ? stamp : undefined;
109
109
  const now = rememberSession(was, id, said.source);
110
110
  book.sessions[session] = withUnclaimed(rules === undefined ? now : { ...now, rules }, unclaimed);
111
111
  forgetClaimed(book);
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
  /**
package/src/up.js CHANGED
@@ -408,12 +408,13 @@ const LIST_LOOK_MS = 2000;
408
408
  const LIST_ASK_MS = 500;
409
409
 
410
410
  /**
411
- * Type LIST_LINE into the tab `tabId` once the gate every typed line goes
412
- * through lets it: `{ typed: true }`, or `{ typed: false, why }` with the
413
- * gate's last reason once LIST_WAIT_MS is up. Nothing goes into a question, a
414
- * form or a menu (#329, #416), nor into a tab the kit cannot tell about.
411
+ * Type `line`, LIST_LINE unless told otherwise, into the tab `tabId` once the
412
+ * gate every typed line goes through lets it: `{ typed: true }`, or
413
+ * `{ typed: false, why }` with the gate's last reason once LIST_WAIT_MS is up.
414
+ * Nothing goes into a question, a form or a menu (#329, #416), nor into a tab
415
+ * the kit cannot tell about.
415
416
  */
416
- async function typeListLine(home, tabId) {
417
+ export async function typeListLine(home, tabId, line = LIST_LINE) {
417
418
  const until = Date.now() + LIST_WAIT_MS;
418
419
  for (;;) {
419
420
  let found;
@@ -430,7 +431,7 @@ async function typeListLine(home, tabId) {
430
431
  }
431
432
  if (found.handle !== undefined) {
432
433
  try {
433
- typeIntoTab(found.handle, LIST_LINE);
434
+ typeIntoTab(found.handle, line);
434
435
  return { typed: true };
435
436
  } catch (error) {
436
437
  return { typed: false, why: `Orca refused the line: ${error.message}` };