@assuredloop/orca-bot-kit 0.1.0 → 0.2.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 +545 -3
  3. package/SETUP.md +270 -0
  4. package/package.json +32 -13
  5. package/rules/evidence.md +15 -0
  6. package/rules/finishing.md +20 -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 +20 -0
  12. package/rules/simple.md +14 -0
  13. package/rules/talk.md +13 -0
  14. package/rules/tests-first.md +15 -0
  15. package/rules/the-ask.md +19 -0
  16. package/skills/obk-arch/LICENSES.md +45 -0
  17. package/skills/obk-arch/NOTICE.md +134 -0
  18. package/skills/obk-arch/SKILL.md +387 -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 +235 -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 +419 -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 +146 -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 +219 -0
  34. package/skills/obk-grilling/LICENSES.md +42 -0
  35. package/skills/obk-grilling/NOTICE.md +103 -0
  36. package/skills/obk-grilling/SKILL.md +217 -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 +219 -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 +259 -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 +408 -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 +516 -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 +169 -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 +1130 -0
  71. package/src/conversations.js +150 -0
  72. package/src/groom.js +157 -0
  73. package/src/health.js +309 -0
  74. package/src/hooks.js +202 -0
  75. package/src/init.js +314 -0
  76. package/src/launch.js +276 -0
  77. package/src/message.js +380 -0
  78. package/src/orca.js +291 -0
  79. package/src/pause.js +58 -0
  80. package/src/record.js +209 -0
  81. package/src/restart.js +192 -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 +629 -0
  86. package/src/sources.js +370 -0
  87. package/src/up.js +475 -0
  88. package/src/usage.js +401 -0
  89. package/bin/okb.js +0 -4
  90. package/src/index.js +0 -11
package/src/launch.js ADDED
@@ -0,0 +1,276 @@
1
+ // What a session is started with: the one line typed into its tab, which brings
2
+ // the harness up and carries the session's start prompt with it.
3
+ //
4
+ // The flag mapping is PRD 6.4 and ADR 0005, re-checked against the installed
5
+ // CLIs (Claude Code 2.1.278, Codex 0.155.1). Every session is given its
6
+ // approval level explicitly, so a user's own global harness defaults cannot
7
+ // leak into a bot. The kit names no model: a session that sets none gets the
8
+ // harness's own.
9
+ //
10
+ // The text goes into the tab's ordinary shell, so every word is quoted the way
11
+ // a shell needs it. `claude --model sonnet[1m]` would be a glob to zsh.
12
+
13
+ import { readFileSync } from 'node:fs';
14
+ import path from 'node:path';
15
+
16
+ /** The approval levels, and what each one is called on each harness. */
17
+ const APPROVAL = {
18
+ claude: {
19
+ auto: ['--permission-mode', 'auto'],
20
+ ask: ['--permission-mode', 'manual'],
21
+ 'dangerously-skip': ['--dangerously-skip-permissions'],
22
+ },
23
+ codex: {
24
+ auto: ['--approve-for-me'],
25
+ ask: ['-a', 'on-request'],
26
+ 'dangerously-skip': ['--dangerously-bypass-approvals-and-sandbox'],
27
+ },
28
+ };
29
+
30
+ export const APPROVALS = Object.keys(APPROVAL.claude);
31
+
32
+ /**
33
+ * How a permission bypass is spelled on a harness: the flags the
34
+ * `dangerously-skip` level puts on the launch line, and the same ones Orca's
35
+ * own default launch arguments carry when they carry a bypass (PRD 6.5).
36
+ */
37
+ export const bypassFlags = (harness) => APPROVAL[harness]['dangerously-skip'];
38
+
39
+ export const HARNESSES = Object.keys(APPROVAL);
40
+
41
+ /** The level a session that names none runs at (ADR 0005). */
42
+ export const DEFAULT_APPROVAL = 'auto';
43
+
44
+ /** A setting the user left out. An empty string is one too: it means the harness's own. */
45
+ const set = (value) => value !== undefined && value !== null && String(value).trim() !== '';
46
+
47
+ /** Which harness a session runs on: its own, or the bot's. */
48
+ export const harnessOf = (session, botHarness) => (set(session.harness) ? session.harness : botHarness);
49
+
50
+ /**
51
+ * A session's name on Claude Code, which is also the address another Claude
52
+ * session writes to (ADR 0008). It goes on the launch line as `-n`, and it is
53
+ * re-applied every time the session is started: a resume keeps the name by
54
+ * itself (tech notes, section 2), and the kit does not depend on that.
55
+ */
56
+ export const addressOf = (bot, session) => `${bot}.${session}`;
57
+
58
+ /**
59
+ * The Codex setting without which a bot cannot reach Orca at all.
60
+ *
61
+ * Inside Codex's workspace-write sandbox the Orca CLI runs but cannot connect
62
+ * to the running app, so `orca orchestration` is refused and a Codex bot can
63
+ * neither read nor send fleet mail. With this switch on, the same session at
64
+ * the same approval level reaches it (tech notes, section 3, proved live).
65
+ * The widening is real and said plainly where the user sees the launch line:
66
+ * that sandbox gains network access, there being no localhost-only setting.
67
+ */
68
+ const NETWORK_ACCESS = 'sandbox_workspace_write.network_access';
69
+
70
+ /**
71
+ * Whether the user has settled that switch themselves in this session's extra
72
+ * arguments. Theirs wins, whichever way they set it: the kit adds nothing
73
+ * beside it, and a session that turned it off is reported as out of reach of
74
+ * fleet mail rather than quietly failing to answer.
75
+ */
76
+ export const setsNetworkAccess = (session) =>
77
+ extraWords(session.extra_args).some((word) => word.includes(NETWORK_ACCESS));
78
+
79
+ /**
80
+ * Whether fleet mail can reach this session at all: everything but a Codex
81
+ * session whose user turned the sandbox switch off.
82
+ */
83
+ export const reachesMail = (session, harness) =>
84
+ harness !== 'codex' || !turnedOff(session);
85
+
86
+ const turnedOff = (session) =>
87
+ extraWords(session.extra_args).some((word) => word.includes(`${NETWORK_ACCESS}=false`));
88
+
89
+ /** Where a session's work dir is: under the bot home, or wherever it says. */
90
+ export function workDirOf(session, home) {
91
+ if (!set(session.work_dir)) return undefined;
92
+ return path.resolve(home, session.work_dir);
93
+ }
94
+
95
+ /**
96
+ * What a session asks for that cannot be done, in the user's own terms.
97
+ *
98
+ * Both commands that write a session check this, and so does the run that
99
+ * starts one: a session can also arrive by hand, and a bot.yaml the user edited
100
+ * is worth the same plain sentence rather than a stray flag typed into a tab.
101
+ */
102
+ export function sessionTrouble(session, harness, home) {
103
+ if (set(session.prompt) && set(session.prompt_file)) {
104
+ return `${where(session)} has both a prompt and a prompt file, and a session is told its duty once. Keep the one you meant.`;
105
+ }
106
+ // The file is the prompt, so a session that cannot be read cannot be started,
107
+ // and saying so now beats a tab that comes up with nothing to do.
108
+ if (set(session.prompt_file)) {
109
+ try {
110
+ readFileSync(promptFile(session, home), 'utf8');
111
+ } catch (error) {
112
+ return `${where(session)} has its prompt in ${session.prompt_file}, and that file cannot be read (${error.code}): ${promptFile(session, home)}. Write it, or point the session at the file you meant.`;
113
+ }
114
+ }
115
+ if (!HARNESSES.includes(harness)) {
116
+ return `${where(session)} runs on ${harness}, and the harnesses are ${HARNESSES.join(' and ')}.`;
117
+ }
118
+ if (set(session.approval) && !APPROVALS.includes(session.approval)) {
119
+ return `${where(session)} is set to approval ${session.approval}, and the levels are ${APPROVALS.join(', ')}.`;
120
+ }
121
+ // Codex is given the context window as a number of tokens. `1m` is what a
122
+ // Claude session would say, and copying it here does not fail at the kit but
123
+ // in Codex, which exits with a type error the moment it starts.
124
+ if (harness === 'codex' && set(session.context) && !/^\d+$/.test(String(session.context).trim())) {
125
+ return `${where(session)} sets a context of ${session.context} on Codex, and Codex counts that in tokens, as a whole number: 200000, for example. A context like 1m belongs on Claude Code, where it is part of the model name.`;
126
+ }
127
+ // On Claude Code the context window is a suffix on the model name, so there
128
+ // is nothing to hang it on when no model was named.
129
+ if (harness === 'claude' && set(session.context) && !set(session.model)) {
130
+ return `${where(session)} sets a context on Claude Code but no model. On Claude Code the context is part of the model name (--model 'sonnet[1m]'), so give the session a model as well, or leave the context out.`;
131
+ }
132
+ return undefined;
133
+ }
134
+
135
+ const where = (session) => (set(session.name) ? `session ${session.name}` : 'this session');
136
+
137
+ /**
138
+ * The line that starts the harness in a session's tab, with the session's start
139
+ * prompt on the end of it as the harness's own prompt argument.
140
+ *
141
+ * Nothing is added for a setting the session left out, and the order is fixed:
142
+ * approval, model, effort, context, work dir, whatever the user added, and the
143
+ * prompt last, where a positional argument goes.
144
+ *
145
+ * The prompt rides on this line rather than being typed in afterwards, which is
146
+ * what a first run in a fresh bots repo taught us: the harness may be showing
147
+ * its folder-trust question, Claude Code's reads as idle to Orca, and a second
148
+ * send would land on that list and answer it. A prompt given as an argument is
149
+ * held by the harness until it is ready for it (tech notes, section 1).
150
+ */
151
+ export function launchCommand(session, { harness, home, workDir, prompt, promptFile: fromFile, resume, address }) {
152
+ const trouble = sessionTrouble(session, harness, home);
153
+ if (trouble !== undefined) throw new Error(trouble);
154
+
155
+ const words = [harness];
156
+ // Codex resumes through a subcommand, which has to come first; Claude Code
157
+ // resumes through a flag, which goes with the session id at the end. Both
158
+ // take every other flag exactly as a fresh session does.
159
+ if (resume !== undefined && harness === 'codex') words.push('resume');
160
+ words.push(...APPROVAL[harness][set(session.approval) ? session.approval : DEFAULT_APPROVAL]);
161
+
162
+ if (harness === 'claude') {
163
+ // The name is the address other Claude sessions write to, so it goes on
164
+ // every launch line, a resume included (ADR 0008).
165
+ if (address !== undefined) words.push('-n', address);
166
+ // The context window rides on the model name: `sonnet[1m]`.
167
+ if (set(session.model)) words.push('--model', set(session.context) ? `${session.model}[${session.context}]` : session.model);
168
+ if (set(session.effort)) words.push('--effort', String(session.effort));
169
+ } else {
170
+ // Without this the Orca CLI cannot reach Orca from inside the sandbox, so
171
+ // the session is in no fleet at all. The user's own setting of it wins.
172
+ if (!setsNetworkAccess(session)) words.push('-c', `${NETWORK_ACCESS}=true`);
173
+ if (set(session.model)) words.push('-m', session.model);
174
+ if (set(session.effort)) words.push('-c', `model_reasoning_effort=${session.effort}`);
175
+ if (set(session.context)) words.push('-c', `model_context_window=${session.context}`);
176
+ // In Codex's auto mode the sandbox lets it write in the folder it was
177
+ // started in, and nowhere else (ADR 0005), so a work dir outside the bot
178
+ // home has to be named.
179
+ if (workDir !== undefined && !inside(home, workDir)) words.push('--add-dir', workDir);
180
+ }
181
+
182
+ return [
183
+ // The shell's own pid, so the session's hook can tell the harness this line
184
+ // starts — the shell's child — from anything the session starts later inside
185
+ // the tab. `$$` is the shell's, and it is not quoted for that reason.
186
+ `${SHELL_ENV}=$$`,
187
+ ...words.map(quoted),
188
+ ...extraWords(session.extra_args),
189
+ ...resumeWords(harness, resume),
190
+ // `--` first: a prompt of the user's own may start with a dash — a
191
+ // Markdown bullet does — and both harnesses would read it as an option of
192
+ // theirs and refuse to start.
193
+ ...promptWords(prompt, fromFile),
194
+ ].join(' ');
195
+ }
196
+
197
+ /**
198
+ * What the session is told when it comes up, or undefined when it has nothing
199
+ * to say. The work dir is named here and nowhere else: it is an instruction to
200
+ * the session, not something the harness is told (PRD 6.4).
201
+ *
202
+ * A long prompt lives in a file in the bot home and the session points at it;
203
+ * a short one is written in the session itself. Either way it is word for word
204
+ * as the user wrote it: it travels as one argument, quoted for the shell it is
205
+ * typed into, so nothing has to be flattened to keep it whole — the two
206
+ * spaces, the newlines and the indentation of a prompt carrying a piece of
207
+ * text or code are the user's, and none of the kit's business.
208
+ */
209
+ export function startPrompt(session, { home, workDir }) {
210
+ const note = workDir === undefined
211
+ ? undefined
212
+ : `Your work dir is ${workDir}. It is a plain folder the kit made for you, not a git worktree.`;
213
+
214
+ // Only the ends are taken off — a `prompt: |` in YAML, and a file, both carry
215
+ // a newline the user never typed. What is inside is theirs.
216
+ const said = [set(session.prompt_file) ? readFileSync(promptFile(session, home), 'utf8') : session.prompt, note]
217
+ .filter(set)
218
+ .map((part) => String(part).trim());
219
+ return said.length === 0 ? undefined : said.join('\n\n');
220
+ }
221
+
222
+ /** Where a session's prompt file is: in the bot home, where the user put it. */
223
+ export const promptFile = (session, home) => path.resolve(home, session.prompt_file);
224
+
225
+ /**
226
+ * A prompt short and simple enough to type into the launch line as it is: one
227
+ * line, and not a long one. Everything else goes to the harness out of a file,
228
+ * which keeps a page of text out of a tab's shell and its history.
229
+ */
230
+ export const isShortPrompt = (prompt) => !prompt.includes('\n') && prompt.length <= SHORT_PROMPT;
231
+
232
+ const SHORT_PROMPT = 200;
233
+
234
+ /**
235
+ * The prompt on the launch line: the text itself when it is short, and
236
+ * otherwise the file the kit wrote it to, read back by the shell that types the
237
+ * line. `"$(cat …)"` hands the harness one argument whatever is in the file.
238
+ */
239
+ function promptWords(prompt, fromFile) {
240
+ if (fromFile !== undefined) return ['--', `"$(cat ${quoted(fromFile)})"`];
241
+ return prompt === undefined ? [] : ['--', quoted(prompt)];
242
+ }
243
+
244
+ /**
245
+ * The session the harness is told to pick up again, when there is one: a flag
246
+ * on Claude Code, the argument of the `resume` subcommand on Codex.
247
+ */
248
+ const resumeWords = (harness, resume) => {
249
+ if (resume === undefined) return [];
250
+ return harness === 'claude' ? ['--resume', quoted(resume)] : [quoted(resume)];
251
+ };
252
+
253
+ /** Whether `target` is the folder at `home` or something inside it. */
254
+ const inside = (home, target) => target === home || target.startsWith(home + path.sep);
255
+
256
+ /**
257
+ * The user's own `extra_args`. A list is a list of arguments, each quoted as
258
+ * one. A plain string is the user's own shell text, typed as they wrote it.
259
+ */
260
+ function extraWords(extra) {
261
+ if (Array.isArray(extra)) return extra.filter(set).map((arg) => quoted(String(arg)));
262
+ return set(extra) ? [String(extra).trim()] : [];
263
+ }
264
+
265
+ /**
266
+ * Where the launch line tells the session's hook which shell typed the harness
267
+ * in. One tab holds one session: the harness this shell starts. Anything that
268
+ * session starts inside the tab is not the session and can never be it.
269
+ */
270
+ export const SHELL_ENV = 'OBK_TAB_SHELL';
271
+
272
+ /** A word as a shell needs it. Anything a shell would read as more than a word is quoted. */
273
+ const quoted = (word) => (/^[A-Za-z0-9,._+:@%/=-]+$/.test(word) ? word : `'${word.replaceAll("'", `'\\''`)}'`);
274
+
275
+ /** The same, for anything else of the kit's that has to build a shell line. */
276
+ export const shellWord = quoted;
package/src/message.js ADDED
@@ -0,0 +1,380 @@
1
+ // `obk message`: one session writing to another (PRD 6.9, ADR 0008).
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, reachesMail } from './launch.js';
24
+ import { ackMailbox, postMessage, readMailbox, tabs, tuiInTab, 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: obk up --bots ${bots} --bot ${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
+ useMailbox(who.mailbox);
196
+ const found = readMailbox(who.mailbox, { peek });
197
+ const messages = (found.messages ?? []).map((message) => asMessage(bots, message));
198
+ if (!peek && found.deliveryId !== undefined && messages.length > 0) ackMailbox(who.mailbox, found.deliveryId);
199
+
200
+ return { bots, bot: who.bot, session: who.session, mailbox: who.mailbox, read: !peek, messages };
201
+ }
202
+
203
+ /** One message, in the kit's words rather than Orca's. */
204
+ const asMessage = (bots, message) => ({
205
+ id: message.id,
206
+ // The mailbox it came from, said as the session it belongs to where the
207
+ // fleet knows it: a reply is written to a session, not to a run id.
208
+ from: whoOwns(bots, message.from_handle) ?? message.from_handle,
209
+ fromMailbox: message.from_handle,
210
+ subject: message.subject,
211
+ body: message.body,
212
+ thread: message.thread_id ?? undefined,
213
+ at: message.created_at,
214
+ });
215
+
216
+ /** Whose mailbox `run:<id>` is, as `<bot>/<session>`, when it is one of the fleet's. */
217
+ function whoOwns(bots, handle) {
218
+ if (typeof handle !== 'string' || !handle.startsWith('run:')) return undefined;
219
+ const mailbox = handle.slice('run:'.length);
220
+
221
+ for (const name of botNames(bots)) {
222
+ const book = readBook(botDir(bots, name));
223
+ for (const [session, entry] of Object.entries(book.sessions)) {
224
+ if (entry?.mailbox === mailbox) return `${name}/${session}`;
225
+ }
226
+ }
227
+ return undefined;
228
+ }
229
+
230
+ /**
231
+ * The session that is writing: the one named, or the one whose Orca tab this
232
+ * command is running in. A caller that is in neither is asked to say, rather
233
+ * than being given somebody else's name.
234
+ */
235
+ function whoIsWriting(bots, sender, tab, asked = '--from <bot>/<session>: which session is writing') {
236
+ if (sender !== undefined) return findSession(bots, sender);
237
+
238
+ const found = tab === undefined ? undefined : sessionInTab(bots, tab);
239
+ if (found === undefined) {
240
+ 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.`);
241
+ }
242
+ return found;
243
+ }
244
+
245
+ /** The session the book says is in this Orca tab, wherever in the fleet it is. */
246
+ function sessionInTab(bots, tab) {
247
+ for (const name of botNames(bots)) {
248
+ const book = readBook(botDir(bots, name));
249
+ for (const [session, entry] of Object.entries(book.sessions)) {
250
+ if (entry?.tab === tab) return findSession(bots, `${name}/${session}`);
251
+ }
252
+ }
253
+ return undefined;
254
+ }
255
+
256
+ /**
257
+ * The message as it will travel: the text itself when it is short enough, and
258
+ * otherwise a file beside the bots folder with a body that names it.
259
+ *
260
+ * The file is the kit's own, next to the bots repo the way start prompts are,
261
+ * so a long message is not written into the user's git status and does not
262
+ * depend on a file of theirs staying where it was when they sent it.
263
+ */
264
+ function bodyOf(bots, { from, to, subject, text, textFile }) {
265
+ const said = textFile === undefined ? text : readText(textFile);
266
+ if (said === undefined || said.trim() === '') {
267
+ throw new Error('message send needs something to say: --text <text> or --text-file <path>, with something in it.');
268
+ }
269
+ if (Buffer.byteLength(said, 'utf8') <= INLINE_LIMIT) return { body: said };
270
+
271
+ const file = path.join(
272
+ `${bots}.messages`,
273
+ `${encodeURIComponent(`${from.bot}.${from.session}`)}.${encodeURIComponent(`${to.bot}.${to.session}`)}.${stamp()}.md`,
274
+ );
275
+ mkdirSync(path.dirname(file), { recursive: true });
276
+ writeFileSync(file, said);
277
+ return {
278
+ body: `${subject} is longer than a message carries, so it is in this file, whole and as it was written:\n${file}`,
279
+ file,
280
+ };
281
+ }
282
+
283
+ function readText(file) {
284
+ try {
285
+ return readFileSync(file, 'utf8');
286
+ } catch (error) {
287
+ throw new Error(`--text-file names ${file}, and that file cannot be read (${error.code}). Write it, or name the one you meant.`);
288
+ }
289
+ }
290
+
291
+ const stamp = () => new Date().toISOString().replaceAll(':', '-').replace('.', '-');
292
+
293
+ /**
294
+ * Tell the receiver's tab that mail is waiting: one line, typed in.
295
+ *
296
+ * Nothing in the mailbox reaches a running harness by itself, and a typed line
297
+ * is taken as the next turn by a busy session rather than cutting into the one
298
+ * it is having. A tab with no TUI in it is not typed into at all — there is
299
+ * nobody there to read it, and the message waits in the mailbox until the
300
+ * session is up. A tab the book does not hold is never typed into on any road.
301
+ *
302
+ * Nor is a tab with something on screen waiting to be answered. A line typed
303
+ * into one of those is not a message: it is an answer to whatever question is
304
+ * up. That is not a worry, it is a thing that happened — in slice 03 a second
305
+ * line went into a tab on Claude Code's folder-trust list, confirmed its
306
+ * default, `No, exit`, and the harness quit (tech notes, section 1). So where
307
+ * Orca says a tab is blocked, nothing is typed and the mail waits.
308
+ *
309
+ * Orca's `blockedReason` catches Codex's screens and not Claude Code's, which
310
+ * answer as idle with nothing said, so this narrows the case rather than
311
+ * closing it. What closes it is nobody sending to a session before its first
312
+ * screens are answered, which is the caller's work either way.
313
+ */
314
+ function nudge(to, from, subject) {
315
+ if (to.tab === undefined) return { nudged: false };
316
+
317
+ try {
318
+ const live = tabs(to.home).find((tab) => tab.tabId === to.tab);
319
+ if (live === undefined) return { nudged: false };
320
+
321
+ const tui = tuiInTab(live.handle, LOOK_MS);
322
+ if (!tui.running) return { nudged: false };
323
+ if (tui.blockedReason !== undefined) return { nudged: false, blocked: tui.blockedReason };
324
+
325
+ typeIntoTab(
326
+ live.handle,
327
+ `Fleet mail from ${from.bot}/${from.session}: ${subject}. Read it with obk message check --bots ${to.bots} --bot ${to.bot} --session ${to.session}`,
328
+ );
329
+ return { nudged: true };
330
+ } catch (error) {
331
+ // The message is already queued, and it is waiting whatever Orca says
332
+ // about the tab. So this is reported rather than thrown: a send that ends
333
+ // in an error the caller reads as "it did not go" would be a lie, and a
334
+ // silent `false` would read as "the session is not up", which is a
335
+ // different thing from "Orca would not say".
336
+ return { nudged: false, nudgeTrouble: error.message };
337
+ }
338
+ }
339
+
340
+ /** `<bot>/<session>`, or a bot on its own when it has only the one session. */
341
+ function splitTarget(target) {
342
+ const parts = target.split('/');
343
+ if (parts.length > 2 || parts.some((part) => part.trim() === '')) {
344
+ throw new Error(`a session is written <bot>/<session>, and got: ${target}`);
345
+ }
346
+ return { bot: parts[0], session: parts[1] };
347
+ }
348
+
349
+ function pickSession(bot, sessionName, target) {
350
+ if (sessionName === undefined) {
351
+ if (bot.sessions.length === 1) return bot.sessions[0];
352
+ if (bot.sessions.length === 0) {
353
+ throw new Error(`${bot.name} has no sessions yet. Add one with obk session add --bots <path> --bot ${bot.name} --name <name>.`);
354
+ }
355
+ throw new Error(`${bot.name} has ${bot.sessions.length} sessions, so say which one: ${bot.sessions.map((session) => `${bot.name}/${session.name}`).join(', ')}.`);
356
+ }
357
+
358
+ const found = bot.sessions.find((session) => session.name === sessionName);
359
+ if (found === undefined) {
360
+ throw new Error(`${bot.name} has no session called ${sessionName}. It has: ${bot.sessions.map((session) => session.name).join(', ') || 'none'}.`);
361
+ }
362
+ return found;
363
+ }
364
+
365
+ /**
366
+ * Claude Code delivers between two sessions of the same class without asking,
367
+ * and holds a message that crosses classes for somebody to approve (tech notes,
368
+ * section 2). The kit's levels map onto the harness's two: the bypassing one is
369
+ * `dangerously-skip`, and everything else prompts.
370
+ */
371
+ const classOf = (session) => (session.approval === 'dangerously-skip' ? 'bypassing' : 'prompting');
372
+
373
+ /** Why fleet mail cannot reach this session at all, when it cannot. */
374
+ function whyNotReachable(bot, session, harness, held) {
375
+ if (reachesMail(session, harness)) return undefined;
376
+ 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.`;
377
+ }
378
+
379
+ const notUpYet = (who) =>
380
+ `${who.bot}/${who.session} has no mailbox yet: it has never been brought up. Start it and it gets one: obk up --bots ${who.bots} --bot ${who.bot} --session ${who.session}`;