@assuredloop/orca-bot-kit 0.1.0 → 0.3.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.
Files changed (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +548 -3
  3. package/SETUP.md +275 -0
  4. package/package.json +32 -13
  5. package/rules/evidence.md +15 -0
  6. package/rules/finishing.md +23 -0
  7. package/rules/limits.md +18 -0
  8. package/rules/mail.md +17 -0
  9. package/rules/profiles.md +9 -0
  10. package/rules/review.md +14 -0
  11. package/rules/scope.md +22 -0
  12. package/rules/simple.md +14 -0
  13. package/rules/talk.md +13 -0
  14. package/rules/tests-first.md +17 -0
  15. package/rules/the-ask.md +19 -0
  16. package/skills/obk-arch/LICENSES.md +52 -0
  17. package/skills/obk-arch/NOTICE.md +242 -0
  18. package/skills/obk-arch/SKILL.md +598 -0
  19. package/skills/obk-bot-building/LICENSES.md +21 -0
  20. package/skills/obk-bot-building/NOTICE.md +113 -0
  21. package/skills/obk-bot-building/SKILL.md +312 -0
  22. package/skills/obk-debugging/LICENSES.md +67 -0
  23. package/skills/obk-debugging/NOTICE.md +115 -0
  24. package/skills/obk-debugging/SKILL.md +434 -0
  25. package/skills/obk-decision-memo/LICENSES.md +49 -0
  26. package/skills/obk-decision-memo/NOTICE.md +181 -0
  27. package/skills/obk-decision-memo/SKILL.md +241 -0
  28. package/skills/obk-finops/LICENSES.md +19 -0
  29. package/skills/obk-finops/NOTICE.md +77 -0
  30. package/skills/obk-finops/SKILL.md +148 -0
  31. package/skills/obk-fleet-review/LICENSES.md +52 -0
  32. package/skills/obk-fleet-review/NOTICE.md +104 -0
  33. package/skills/obk-fleet-review/SKILL.md +256 -0
  34. package/skills/obk-grilling/LICENSES.md +42 -0
  35. package/skills/obk-grilling/NOTICE.md +111 -0
  36. package/skills/obk-grilling/SKILL.md +221 -0
  37. package/skills/obk-grooming/LICENSES.md +52 -0
  38. package/skills/obk-grooming/NOTICE.md +111 -0
  39. package/skills/obk-grooming/SKILL.md +222 -0
  40. package/skills/obk-handoff/LICENSES.md +45 -0
  41. package/skills/obk-handoff/NOTICE.md +51 -0
  42. package/skills/obk-handoff/SKILL.md +119 -0
  43. package/skills/obk-personal-facilitation/LICENSES.md +53 -0
  44. package/skills/obk-personal-facilitation/NOTICE.md +62 -0
  45. package/skills/obk-personal-facilitation/SKILL.md +86 -0
  46. package/skills/obk-recall/LICENSES.md +42 -0
  47. package/skills/obk-recall/NOTICE.md +53 -0
  48. package/skills/obk-recall/SKILL.md +129 -0
  49. package/skills/obk-researching/LICENSES.md +67 -0
  50. package/skills/obk-researching/NOTICE.md +170 -0
  51. package/skills/obk-researching/SKILL.md +269 -0
  52. package/skills/obk-reviewing/LICENSES.md +66 -0
  53. package/skills/obk-reviewing/NOTICE.md +58 -0
  54. package/skills/obk-reviewing/SKILL.md +411 -0
  55. package/skills/obk-tdd/LICENSES.md +68 -0
  56. package/skills/obk-tdd/NOTICE.md +54 -0
  57. package/skills/obk-tdd/SKILL.md +547 -0
  58. package/skills/obk-tdd/mutation-tools.md +80 -0
  59. package/skills/obk-teach/LICENSES.md +42 -0
  60. package/skills/obk-teach/NOTICE.md +65 -0
  61. package/skills/obk-teach/SKILL.md +106 -0
  62. package/skills/obk-why/LICENSES.md +42 -0
  63. package/skills/obk-why/NOTICE.md +74 -0
  64. package/skills/obk-why/SKILL.md +180 -0
  65. package/skills/obk-writing/LICENSES.md +272 -0
  66. package/skills/obk-writing/NOTICE.md +161 -0
  67. package/skills/obk-writing/SKILL.md +254 -0
  68. package/src/book.js +268 -0
  69. package/src/bot.js +451 -0
  70. package/src/cli.js +1169 -0
  71. package/src/conversations.js +150 -0
  72. package/src/groom.js +159 -0
  73. package/src/health.js +309 -0
  74. package/src/hooks.js +257 -0
  75. package/src/init.js +314 -0
  76. package/src/launch.js +299 -0
  77. package/src/message.js +402 -0
  78. package/src/orca.js +362 -0
  79. package/src/pause.js +63 -0
  80. package/src/record.js +209 -0
  81. package/src/restart.js +193 -0
  82. package/src/retire.js +92 -0
  83. package/src/roster.js +71 -0
  84. package/src/rules.js +408 -0
  85. package/src/skills.js +630 -0
  86. package/src/sources.js +371 -0
  87. package/src/up.js +500 -0
  88. package/src/usage.js +401 -0
  89. package/bin/okb.js +0 -4
  90. package/src/index.js +0 -11
package/src/message.js ADDED
@@ -0,0 +1,402 @@
1
+ // `obk message`: one session writing to another (PRD 6.9, ADR 0018).
2
+ //
3
+ // Two roads, and the bot never picks. Claude to Claude in the same approval
4
+ // class is the harness's own messaging, which no command line can send for it —
5
+ // so the kit answers with the address and says to use it. Everything else goes
6
+ // through the Orca mailbox, which the kit does carry.
7
+ //
8
+ // What the mailbox is, and why it is not the tab: Orca calls a terminal a live
9
+ // terminal-only mailbox, warns that delivery does not outlive the tab, and
10
+ // refuses a send once the pane is gone. A Run survives the tab, the relaunch
11
+ // and the restart, so a session's address is its Run (tech notes, section 1).
12
+ //
13
+ // Nothing in a mailbox wakes the session it belongs to — a message addressed to
14
+ // a tab leaves the harness in it untouched, proved live — so a send also types
15
+ // one line into the receiver's tab. Both harnesses queue a typed line while
16
+ // they are busy, which is what PRD 6.9 means by queued and not interrupting.
17
+
18
+ import { mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
19
+ import path from 'node:path';
20
+
21
+ import { readBook } from './book.js';
22
+ import { botDir, botNames, readBot } from './bot.js';
23
+ import { harnessOf, ownCli, reachesMail, shellWord } from './launch.js';
24
+ import { ackMailbox, harnessInTab, postMessage, readMailbox, tabs, typeIntoTab, useMailbox } from './orca.js';
25
+
26
+ /**
27
+ * How much of a message travels as itself. Above this it is written to a file
28
+ * beside the bots folder and the message names the file, which is PRD 6.9's one
29
+ * rule with no judgement in it.
30
+ *
31
+ * The number is the receiver's, not Orca's: 200 KB goes through the mailbox
32
+ * whole, but it lands in a session's context, and a command line dies near a
33
+ * megabyte anyway. Four kilobytes is a message; more than that is a document,
34
+ * and a document has a name.
35
+ */
36
+ export const INLINE_LIMIT = 4 * 1024;
37
+
38
+ /** How long Orca is given to say whether a tab has a TUI in it to nudge. */
39
+ const LOOK_MS = 2000;
40
+
41
+ /**
42
+ * Everything the kit knows about one session as somebody to write to: which
43
+ * harness it runs on, which approval class it is in, its mailbox, its name on
44
+ * Claude Code, and the tab it is in. `trouble` is the one sentence that says
45
+ * why it cannot be written to, when it cannot.
46
+ */
47
+ export function findSession(bots, target) {
48
+ const { bot: botName, session: sessionName } = splitTarget(target);
49
+
50
+ const names = botNames(bots);
51
+ if (!names.includes(botName)) {
52
+ throw new Error(`there is no bot called ${botName} in ${bots}. The bots there are: ${names.join(', ')}.`);
53
+ }
54
+
55
+ // Resolved, because Orca is asked about this folder by path and does not
56
+ // follow a link to it (tech notes, section 1).
57
+ const home = realpathSync(botDir(bots, botName));
58
+ const bot = readBot(home, botName);
59
+ const session = pickSession(bot, sessionName, target);
60
+ const harness = harnessOf(session, bot.harness);
61
+ const held = readBook(home).sessions[session.name] ?? {};
62
+
63
+ return {
64
+ bots,
65
+ home,
66
+ bot: bot.name,
67
+ session: session.name,
68
+ harness,
69
+ approval: classOf(session),
70
+ address: typeof held.address === 'string' ? held.address : undefined,
71
+ mailbox: typeof held.mailbox === 'string' ? held.mailbox : undefined,
72
+ tab: typeof held.tab === 'string' ? held.tab : undefined,
73
+ trouble: whyNotReachable(bot.name, session, harness, held),
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Which road a message between these two takes, and the address it goes to.
79
+ *
80
+ * Native is the harness's own cross-session messaging: Claude Code to Claude
81
+ * Code, and only where both sit in the same approval class, because a message
82
+ * across classes is held for somebody to approve and the kit chooses a road in
83
+ * advance rather than working around a hold (PRD 6.9).
84
+ */
85
+ export function roadBetween(from, to) {
86
+ const pair = from.harness === 'claude' && to.harness === 'claude' && from.approval === to.approval;
87
+ if (pair && to.address !== undefined) return { transport: 'native', address: to.address };
88
+
89
+ return {
90
+ transport: 'orca',
91
+ address: to.mailbox === undefined ? undefined : `run:${to.mailbox}`,
92
+ // A Claude pair that the native road cannot carry, because the receiver is
93
+ // running under no name the kit gave it: it was started before the kit
94
+ // named sessions, and nothing renames a live harness. The mailbox is the
95
+ // road that exists, and the caller is told why it is the one being used
96
+ // rather than left to wonder (review of PR #132, finding 1).
97
+ unnamed: pair ? true : undefined,
98
+ };
99
+ }
100
+
101
+ /** What `obk message to` answers: the road, the address, and who is at each end. */
102
+ export function lookUp(bots, { to: target, from: sender, tab }) {
103
+ const from = whoIsWriting(bots, sender, tab);
104
+ const to = findSession(bots, target);
105
+ const road = roadBetween(from, to);
106
+
107
+ return {
108
+ bots,
109
+ from: { bot: from.bot, session: from.session, harness: from.harness },
110
+ to: { bot: to.bot, session: to.session, harness: to.harness },
111
+ ...road,
112
+ trouble: to.trouble ?? (road.address === undefined ? notUpYet(to) : undefined),
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Send one message down the Orca road, and tell the receiver's tab to look.
118
+ *
119
+ * A native pair is not carried here: no command line can send a Claude session's
120
+ * own message. The kit says which address to write to instead, and sends
121
+ * nothing, which is the whole of "the bot never picks the transport".
122
+ */
123
+ export function sendMessage(bots, { to: target, from: sender, tab, subject, text, textFile, thread }) {
124
+ const from = whoIsWriting(bots, sender, tab);
125
+ const to = findSession(bots, target);
126
+ const road = roadBetween(from, to);
127
+
128
+ const answer = {
129
+ bots,
130
+ from: { bot: from.bot, session: from.session },
131
+ to: { bot: to.bot, session: to.session },
132
+ subject,
133
+ ...road,
134
+ };
135
+
136
+ if (road.transport === 'native') {
137
+ return {
138
+ ...answer,
139
+ sent: false,
140
+ trouble: `${to.bot}/${to.session} is a Claude session in your own approval class, so it is written to with your own harness's messaging, not through Orca. Its address is ${to.address}.`,
141
+ };
142
+ }
143
+
144
+ if (to.trouble !== undefined) return { ...answer, sent: false, trouble: to.trouble };
145
+ if (road.address === undefined) return { ...answer, sent: false, trouble: notUpYet(to) };
146
+ if (from.mailbox === undefined) {
147
+ return {
148
+ ...answer,
149
+ sent: false,
150
+ trouble: `${from.bot}/${from.session} has no mailbox of its own yet, so a reply would have nowhere to go. Bring it up first: ${shellWord(ownCli())} up --bots ${shellWord(bots)} --bot ${shellWord(from.bot)}`,
151
+ };
152
+ }
153
+
154
+ const written = bodyOf(bots, { from, to, subject, text, textFile });
155
+ const message = postMessage({
156
+ to: road.address,
157
+ from: `run:${from.mailbox}`,
158
+ subject,
159
+ body: written.body,
160
+ thread,
161
+ });
162
+
163
+ return {
164
+ ...answer,
165
+ sent: true,
166
+ id: message.id,
167
+ thread: message.thread_id ?? thread,
168
+ file: written.file,
169
+ ...nudge(to, from, subject),
170
+ };
171
+ }
172
+
173
+ /**
174
+ * What is waiting for a session, and the reading of it.
175
+ *
176
+ * Orca fences a Run to one reader, so this binds before it reads. A read that
177
+ * is not a peek acknowledges the batch it read: that is what makes the next
178
+ * check bring the next one rather than the same again.
179
+ */
180
+ export function checkMail(bots, { bot: botName, session: sessionName, tab, peek = false }) {
181
+ const who = sessionName === undefined && botName === undefined
182
+ ? whoIsWriting(bots, undefined, tab, '--bot <bot> [--session <name>]: whose mail to read')
183
+ : findSession(bots, sessionName === undefined ? botName : `${botName}/${sessionName}`);
184
+
185
+ if (who.mailbox === undefined) {
186
+ return {
187
+ bots,
188
+ bot: who.bot,
189
+ session: who.session,
190
+ messages: [],
191
+ trouble: who.trouble ?? notUpYet(who),
192
+ };
193
+ }
194
+
195
+ // Read as the session's own tab, wherever this check was typed. Binding the
196
+ // tab that asked would hand it the session's mailbox, and Orca's notice for
197
+ // every message after (issue #228). A session with no live tab is read as
198
+ // this process's own terminal, as it always was.
199
+ const handle = who.tab === undefined ? undefined : tabs(who.home).find((tab) => tab.tabId === who.tab)?.handle;
200
+ useMailbox(who.mailbox, handle);
201
+ const found = readMailbox(who.mailbox, { peek, handle });
202
+ const messages = (found.messages ?? []).map((message) => asMessage(bots, message));
203
+ if (!peek && found.deliveryId !== undefined && messages.length > 0) ackMailbox(who.mailbox, found.deliveryId, handle);
204
+
205
+ return { bots, bot: who.bot, session: who.session, mailbox: who.mailbox, read: !peek, messages };
206
+ }
207
+
208
+ /** One message, in the kit's words rather than Orca's. */
209
+ const asMessage = (bots, message) => ({
210
+ id: message.id,
211
+ // The mailbox it came from, said as the session it belongs to where the
212
+ // fleet knows it: a reply is written to a session, not to a run id.
213
+ from: whoOwns(bots, message.from_handle) ?? message.from_handle,
214
+ fromMailbox: message.from_handle,
215
+ subject: message.subject,
216
+ body: message.body,
217
+ thread: message.thread_id ?? undefined,
218
+ at: message.created_at,
219
+ });
220
+
221
+ /** Whose mailbox `run:<id>` is, as `<bot>/<session>`, when it is one of the fleet's. */
222
+ function whoOwns(bots, handle) {
223
+ if (typeof handle !== 'string' || !handle.startsWith('run:')) return undefined;
224
+ const mailbox = handle.slice('run:'.length);
225
+
226
+ for (const name of botNames(bots)) {
227
+ const book = readBook(botDir(bots, name));
228
+ for (const [session, entry] of Object.entries(book.sessions)) {
229
+ if (entry?.mailbox === mailbox) return `${name}/${session}`;
230
+ }
231
+ }
232
+ return undefined;
233
+ }
234
+
235
+ /**
236
+ * The session that is writing: the one named, or the one whose Orca tab this
237
+ * command is running in. A caller that is in neither is asked to say, rather
238
+ * than being given somebody else's name.
239
+ */
240
+ function whoIsWriting(bots, sender, tab, asked = '--from <bot>/<session>: which session is writing') {
241
+ if (sender !== undefined) return findSession(bots, sender);
242
+
243
+ const found = tab === undefined ? undefined : sessionInTab(bots, tab);
244
+ if (found === undefined) {
245
+ throw new Error(`${asked}. The kit reads it from the tab when it runs in one, and this is not one of the fleet's tabs.`);
246
+ }
247
+ return found;
248
+ }
249
+
250
+ /** The session the book says is in this Orca tab, wherever in the fleet it is. */
251
+ function sessionInTab(bots, tab) {
252
+ for (const name of botNames(bots)) {
253
+ const book = readBook(botDir(bots, name));
254
+ for (const [session, entry] of Object.entries(book.sessions)) {
255
+ if (entry?.tab === tab) return findSession(bots, `${name}/${session}`);
256
+ }
257
+ }
258
+ return undefined;
259
+ }
260
+
261
+ /**
262
+ * The message as it will travel: the text itself when it is short enough, and
263
+ * otherwise a file beside the bots folder with a body that names it.
264
+ *
265
+ * The file is the kit's own, next to the bots repo the way start prompts are,
266
+ * so a long message is not written into the user's git status and does not
267
+ * depend on a file of theirs staying where it was when they sent it.
268
+ */
269
+ function bodyOf(bots, { from, to, subject, text, textFile }) {
270
+ const said = textFile === undefined ? text : readText(textFile);
271
+ if (said === undefined || said.trim() === '') {
272
+ throw new Error('message send needs something to say: --text <text> or --text-file <path>, with something in it.');
273
+ }
274
+ if (Buffer.byteLength(said, 'utf8') <= INLINE_LIMIT) return { body: said };
275
+
276
+ const file = path.join(
277
+ `${bots}.messages`,
278
+ `${encodeURIComponent(`${from.bot}.${from.session}`)}.${encodeURIComponent(`${to.bot}.${to.session}`)}.${stamp()}.md`,
279
+ );
280
+ mkdirSync(path.dirname(file), { recursive: true });
281
+ writeFileSync(file, said);
282
+ return {
283
+ body: `${subject} is longer than a message carries, so it is in this file, whole and as it was written:\n${file}`,
284
+ file,
285
+ };
286
+ }
287
+
288
+ function readText(file) {
289
+ try {
290
+ return readFileSync(file, 'utf8');
291
+ } catch (error) {
292
+ throw new Error(`--text-file names ${file}, and that file cannot be read (${error.code}). Write it, or name the one you meant.`);
293
+ }
294
+ }
295
+
296
+ const stamp = () => new Date().toISOString().replaceAll(':', '-').replace('.', '-');
297
+
298
+ /**
299
+ * Tell the receiver's tab that mail is waiting: one line, typed in.
300
+ *
301
+ * Nothing in the mailbox reaches a running harness by itself, and a typed line
302
+ * is taken as the next turn by a busy session rather than cutting into the one
303
+ * it is having. A tab with no harness in it is not typed into at all — there is
304
+ * nobody there to read it, and the message waits in the mailbox until the
305
+ * session is up. Nor is one the kit cannot tell about. A tab the book does not
306
+ * hold is never typed into on any road.
307
+ *
308
+ * Nor is a tab with something on screen waiting to be answered. A line typed
309
+ * into one of those is not a message: it is an answer to whatever question is
310
+ * up. That is not a worry, it is a thing that happened — in slice 03 a second
311
+ * line went into a tab on Claude Code's folder-trust list, confirmed its
312
+ * default, `No, exit`, and the harness quit (tech notes, section 1). So where
313
+ * Orca says a tab is blocked, nothing is typed and the mail waits.
314
+ *
315
+ * Orca's `blockedReason` catches Codex's screens and not Claude Code's, which
316
+ * answer as idle with nothing said, so this narrows the case rather than
317
+ * closing it. What closes it is nobody sending to a session before its first
318
+ * screens are answered, which is the caller's work either way.
319
+ */
320
+ function nudge(to, from, subject) {
321
+ if (to.tab === undefined) return { nudged: false };
322
+
323
+ try {
324
+ const live = tabs(to.home).find((tab) => tab.tabId === to.tab);
325
+ if (live === undefined) return { nudged: false };
326
+
327
+ const seen = harnessInTab(live.handle, LOOK_MS);
328
+ if (seen.blockedReason !== undefined) return { nudged: false, blocked: seen.blockedReason };
329
+ // The shell in front is a tab with no harness, whatever a stale
330
+ // `agentIdentity` says.
331
+ if (seen.front === 'shell') return { nudged: false };
332
+ // Not knowing is not a reason to type: a line that lands in a shell is run
333
+ // there, with the sender's subject in it. The program in front has to be
334
+ // the agent Orca names. With no name it may be a harness a few seconds
335
+ // into its launch; under another name it may be a pager started after the
336
+ // harness quit, under an identity Orca has not let go of (tech notes,
337
+ // section 1). A harness run through a wrapper such as `node` lands here
338
+ // too, until #261.
339
+ if (seen.front === undefined || seen.agent === undefined || seen.command !== seen.agent) {
340
+ const why = seen.front === undefined
341
+ ? seen.unreadable
342
+ : `${seen.command} holds its terminal, and Orca names ${seen.agent ?? 'no agent'} in it`;
343
+ return { nudged: false, nudgeTrouble: `the kit could not tell whether a harness is running in it (${why}), so nothing was typed` };
344
+ }
345
+ // A busy harness is typed into: it takes the line as its next turn.
346
+
347
+ typeIntoTab(
348
+ live.handle,
349
+ `Fleet mail from ${from.bot}/${from.session}: ${subject}. Read it with ${shellWord(ownCli())} message check --bots ${shellWord(to.bots)} --bot ${shellWord(to.bot)} --session ${shellWord(to.session)}`,
350
+ );
351
+ return { nudged: true };
352
+ } catch (error) {
353
+ // The message is already queued, and it is waiting whatever Orca says
354
+ // about the tab. So this is reported rather than thrown: a send that ends
355
+ // in an error the caller reads as "it did not go" would be a lie, and a
356
+ // silent `false` would read as "the session is not up", which is a
357
+ // different thing from "Orca would not say".
358
+ return { nudged: false, nudgeTrouble: error.message };
359
+ }
360
+ }
361
+
362
+ /** `<bot>/<session>`, or a bot on its own when it has only the one session. */
363
+ function splitTarget(target) {
364
+ const parts = target.split('/');
365
+ if (parts.length > 2 || parts.some((part) => part.trim() === '')) {
366
+ throw new Error(`a session is written <bot>/<session>, and got: ${target}`);
367
+ }
368
+ return { bot: parts[0], session: parts[1] };
369
+ }
370
+
371
+ function pickSession(bot, sessionName, target) {
372
+ if (sessionName === undefined) {
373
+ if (bot.sessions.length === 1) return bot.sessions[0];
374
+ if (bot.sessions.length === 0) {
375
+ throw new Error(`${bot.name} has no sessions yet. Add one with obk session add --bots <path> --bot ${bot.name} --name <name>.`);
376
+ }
377
+ throw new Error(`${bot.name} has ${bot.sessions.length} sessions, so say which one: ${bot.sessions.map((session) => `${bot.name}/${session.name}`).join(', ')}.`);
378
+ }
379
+
380
+ const found = bot.sessions.find((session) => session.name === sessionName);
381
+ if (found === undefined) {
382
+ throw new Error(`${bot.name} has no session called ${sessionName}. It has: ${bot.sessions.map((session) => session.name).join(', ') || 'none'}.`);
383
+ }
384
+ return found;
385
+ }
386
+
387
+ /**
388
+ * Claude Code delivers between two sessions of the same class without asking,
389
+ * and holds a message that crosses classes for somebody to approve (tech notes,
390
+ * section 2). The kit's levels map onto the harness's two: the bypassing one is
391
+ * `dangerously-skip`, and everything else prompts.
392
+ */
393
+ const classOf = (session) => (session.approval === 'dangerously-skip' ? 'bypassing' : 'prompting');
394
+
395
+ /** Why fleet mail cannot reach this session at all, when it cannot. */
396
+ function whyNotReachable(bot, session, harness, held) {
397
+ if (reachesMail(session, harness)) return undefined;
398
+ return `${bot}/${session.name} is a Codex session whose extra arguments turn sandbox_workspace_write.network_access off. Orca is out of reach from inside that sandbox, so it can neither be written to nor read its own mail. Take that argument out, or write to it another way.`;
399
+ }
400
+
401
+ const notUpYet = (who) =>
402
+ `${who.bot}/${who.session} has no mailbox yet: it has never been brought up. Start it and it gets one: ${shellWord(ownCli())} up --bots ${shellWord(who.bots)} --bot ${shellWord(who.bot)} --session ${shellWord(who.session)}`;