@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/orca.js ADDED
@@ -0,0 +1,291 @@
1
+ // Every call the kit makes to Orca goes through here (ADR 0001). Orca's CLI
2
+ // changes often, so the kit reads `--json` and never the human text, and keeps
3
+ // the parsing in one place.
4
+ //
5
+ // A word on words: Orca calls every workspace a "worktree", a plain folder
6
+ // included. Nothing the user reads may say that, or they will think a git
7
+ // worktree was made. Say "Orca project". The word belongs only in the selector
8
+ // handed to Orca on the command line.
9
+
10
+ import { spawnSync } from 'node:child_process';
11
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
12
+ import { homedir } from 'node:os';
13
+ import path from 'node:path';
14
+
15
+ // The Orca that works for a normal user: `/usr/local/bin/orca` is a root-only
16
+ // symlink on this machine (tech notes, section 1). OBK_ORCA overrides it, for a
17
+ // machine that keeps Orca somewhere else.
18
+ const BUILT_IN = '/Applications/Orca.app/Contents/Resources/bin/orca';
19
+
20
+ /** The Orca CLI this run talks to. */
21
+ export const orcaCli = () => process.env.OBK_ORCA || BUILT_IN;
22
+
23
+ /**
24
+ * Run one Orca command and give back its `result`.
25
+ * Every call asks for `--json`; anything else would be text to guess at.
26
+ */
27
+ export function orca(args) {
28
+ const answer = ask(args);
29
+ if (answer.ok !== true) {
30
+ // Orca's own words: it knows what went wrong, and the caller is an LLM
31
+ // that can act on them.
32
+ throw new Error(`Orca refused ${args.join(' ')}: ${answer.error?.message ?? 'no reason given'}`);
33
+ }
34
+ return answer.result;
35
+ }
36
+
37
+ /** One Orca command, and the whole envelope back, refusals included. */
38
+ function ask(args) {
39
+ const asked = spawnSync(orcaCli(), [...args, '--json'], { encoding: 'utf8' });
40
+ if (asked.error) {
41
+ throw new Error(`could not run Orca at ${orcaCli()}: ${asked.error.message}`);
42
+ }
43
+
44
+ const answer = parse(asked.stdout);
45
+ if (answer === null || typeof answer !== 'object') {
46
+ throw new Error(`Orca answered ${args.join(' ')} with something that is not JSON.`);
47
+ }
48
+ return answer;
49
+ }
50
+
51
+ function parse(text) {
52
+ try {
53
+ return JSON.parse(text);
54
+ } catch {
55
+ return undefined;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * What is wrong with Orca, or undefined when nothing is: it is up and its
61
+ * runtime is reachable, the only state in which any of the calls below mean
62
+ * anything. Asking is cheap, and asking first is what turns "Orca refused
63
+ * terminal create" into a sentence the caller can act on.
64
+ */
65
+ export function orcaTrouble() {
66
+ const where = `Start Orca and run the command again, or point OBK_ORCA at its CLI if it lives somewhere else.`;
67
+
68
+ const asked = spawnSync(orcaCli(), ['status', '--json'], { encoding: 'utf8' });
69
+ if (asked.error) return `could not run Orca at ${orcaCli()}: ${asked.error.message}. ${where}`;
70
+
71
+ const answer = parse(asked.stdout);
72
+ // Orca knowing what is wrong beats the kit guessing, so its own words go
73
+ // first when it gave any.
74
+ if (answer !== null && typeof answer === 'object' && answer.ok !== true && answer.error?.message) {
75
+ return `Orca is not ready: ${answer.error.message}. ${where}`;
76
+ }
77
+ // `null` is valid JSON with nothing to say, and so is anything else that
78
+ // does not carry a runtime calling itself reachable.
79
+ if (answer?.ok === true && answer.result?.runtime?.reachable === true) return undefined;
80
+
81
+ return `Orca is not answering at ${orcaCli()}. ${where}`;
82
+ }
83
+
84
+ /** Every workspace Orca has a record of, folder and git alike. */
85
+ export const projects = () => orca(['project', 'setups']).setups;
86
+
87
+ /** Orca's record of the folder at `home`, or undefined when it knows none. */
88
+ export function findProject(home) {
89
+ return projects().find((setup) => setup.path === home);
90
+ }
91
+
92
+ /** Where Orca keeps its own settings: one file per profile, under the user's home. */
93
+ export const profilesDir = () => path.join(homedir(), 'Library', 'Application Support', 'orca', 'profiles');
94
+
95
+ /**
96
+ * Orca's own default launch arguments, per profile it keeps:
97
+ * `{ dir, profiles: [{ file, args }] }`, where `args` maps an agent name to the
98
+ * arguments Orca adds to it, and is undefined for a file that cannot be read.
99
+ *
100
+ * Orca adds these to the agents it launches, relaunches and resumes itself, so
101
+ * they override the approval level a session was started with (PRD 6.5). The
102
+ * kit reads the file and never writes it.
103
+ *
104
+ * A mapping with no entry for an agent is not "no arguments": Orca falls back
105
+ * to its own built-in default, which is the permission bypass itself (tech
106
+ * notes, section 1). So the caller reads a missing entry as one.
107
+ */
108
+ export function orcaDefaultArgs() {
109
+ const dir = profilesDir();
110
+
111
+ let profiles;
112
+ try {
113
+ profiles = readdirSync(dir);
114
+ } catch {
115
+ return { dir, profiles: [] };
116
+ }
117
+
118
+ return {
119
+ dir,
120
+ profiles: profiles
121
+ .map((name) => path.join(dir, name, 'orca-data.json'))
122
+ .filter((file) => existsSync(file))
123
+ .map((file) => ({ file, args: argsIn(file) })),
124
+ };
125
+ }
126
+
127
+ /** What one profile file says about the agents' default arguments. */
128
+ function argsIn(file) {
129
+ try {
130
+ const settings = JSON.parse(readFileSync(file, 'utf8'))?.settings?.agentDefaultArgs;
131
+ // Anything that is not a mapping of agents is a mapping with nothing in it,
132
+ // which is Orca falling back to its own defaults for every agent.
133
+ return settings !== null && typeof settings === 'object' && !Array.isArray(settings) ? settings : {};
134
+ } catch {
135
+ return undefined;
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Register the folder at `home` and make it a folder workspace called `title`.
141
+ *
142
+ * Two calls, and both are needed: `repo add` registers a folder inside the bots
143
+ * repo as git kind, which Orca gives no workspace at all, so no tab can be
144
+ * created in it. `setup-update` turns it into the folder workspace a bot needs
145
+ * and names the project in one go.
146
+ */
147
+ export function makeProject(home, title) {
148
+ const setupId = orca(['repo', 'add', '--path', home]).repo.id;
149
+ return asFolderProject(setupId, title);
150
+ }
151
+
152
+ /** Make an existing registration a folder workspace called `title`. */
153
+ export function asFolderProject(setupId, title) {
154
+ return orca(['project', 'setup-update', '--setup', setupId, '--kind', 'folder', '--display-name', title])
155
+ .result.setup;
156
+ }
157
+
158
+ /**
159
+ * Take a bot's Orca project away: the setup, the project and the repo record,
160
+ * in one call (tech notes, section 1). Only after its tabs are closed: a
161
+ * project taken away first leaves tabs no command line can reach.
162
+ */
163
+ export const deleteProject = (setupId) => orca(['project', 'setup-delete', '--setup', setupId]);
164
+
165
+ /**
166
+ * The live tabs of the Orca project at `home`, each under its own tab id.
167
+ *
168
+ * The listing gives a tab whose pane the window has not loaded as orphaned,
169
+ * under `pty:<ptyId>` rather than its id, and a running harness puts a tab the
170
+ * kit opened in that state within seconds. `terminal show` still answers with
171
+ * the real id, so an orphaned entry is given that one: the tab id is the key
172
+ * (PRD 6.2), and this is where Orca is asked for it (tech notes, section 1).
173
+ */
174
+ export const tabs = (home) => orca(['terminal', 'list', '--worktree', `path:${home}`]).terminals
175
+ .map((tab) => (tab.orphaned === true
176
+ ? { ...tab, tabId: orca(['terminal', 'show', '--terminal', tab.handle]).terminal.tabId }
177
+ : tab));
178
+
179
+ /**
180
+ * Open a tab in the Orca project at `home`, titled `title`.
181
+ *
182
+ * No `--command`: for a project the kit has just made, `--command` with a
183
+ * harness times out and leaves a tab that never goes live (tech notes). The
184
+ * launch command is typed in afterwards. No `--focus` either: the user is
185
+ * working, and the kit does not take his screen.
186
+ */
187
+ export const openTab = (home, title) =>
188
+ orca(['terminal', 'create', '--worktree', `path:${home}`, '--title', title]).terminal;
189
+
190
+ /** Set a tab's title. The kit sets titles; it never reads them. */
191
+ export const retitleTab = (handle, title) =>
192
+ orca(['terminal', 'rename', '--terminal', handle, '--title', title]).rename;
193
+
194
+ /**
195
+ * Whether a TUI is running in the tab, and what it is waiting on if Orca says.
196
+ *
197
+ * `tui-idle` asks about a TUI, not about a shell: a tab sitting at a shell
198
+ * prompt is refused with `timeout` however long you wait, a TUI that is up
199
+ * answers `ok:true`, and `satisfied` only tells idle from busy. So this is the
200
+ * way to learn whether a harness actually started — and `blockedReason` is
201
+ * Orca saying something on screen wants answering, which is for the caller to
202
+ * deal with, not the kit.
203
+ */
204
+ export function tuiInTab(handle, timeoutMs) {
205
+ const args = ['terminal', 'wait', '--terminal', handle, '--for', 'tui-idle', '--timeout-ms', String(timeoutMs)];
206
+ const answer = ask(args);
207
+
208
+ if (answer.ok !== true) {
209
+ // Out of time means no TUI came up. That is an answer, not a breakdown.
210
+ if (answer.error?.code === 'timeout') return { running: false };
211
+ throw new Error(`Orca refused ${args.join(' ')}: ${answer.error?.message ?? 'no reason given'}`);
212
+ }
213
+ return { running: true, blockedReason: answer.result?.wait?.blockedReason };
214
+ }
215
+
216
+ /**
217
+ * Close one tab, by the handle Orca issued for it.
218
+ *
219
+ * The only place in the kit that takes anything away, and it takes away one
220
+ * tab: `--terminal <handle> --tab`. Orca's other form, `--worktree <sel> --all`,
221
+ * closes every tab of a project with its layouts and resume records, which are
222
+ * the user's work and not the kit's to end (tech notes, section 1).
223
+ */
224
+ export const closeTab = (handle) =>
225
+ orca(['terminal', 'close', '--terminal', handle, '--tab']).close;
226
+
227
+ /**
228
+ * Type `text` into a tab and press return.
229
+ *
230
+ * Orca sometimes gates a line going into a tab that has an agent in it and
231
+ * answers `agent_prompt_blocked`, carrying a request id and telling the caller
232
+ * to re-issue the exact command with that id and a time to wait for the line to
233
+ * be submitted — and not to retry without it. So that is what this does, once.
234
+ * Seen live on 2026-09-21 in a system test run; not reproduced since, including
235
+ * from a plain shell, long lines and lines sent while the agent was working.
236
+ *
237
+ * The wait is short because the caller is telling a session it has mail, not
238
+ * handing it work: mail that has to wait for the next check is a smaller cost
239
+ * than a command that hangs for half a minute.
240
+ */
241
+ export function typeIntoTab(handle, text) {
242
+ const args = ['terminal', 'send', '--terminal', handle, '--text', text, '--enter'];
243
+ const answer = ask(args);
244
+ if (answer.ok === true) return answer.result.send;
245
+
246
+ const again = answer.error?.data?.orchestrationRequestId;
247
+ if (answer.error?.code !== 'agent_prompt_blocked' || typeof again !== 'string') {
248
+ throw new Error(`Orca refused ${args.join(' ')}: ${answer.error?.message ?? 'no reason given'}`);
249
+ }
250
+ return orca([...args, '--retry-request', again, '--wait-submit', String(SUBMIT_WAIT_S)]).send;
251
+ }
252
+
253
+ /** How long a re-issued line is given to be submitted before Orca gives up on it. */
254
+ const SUBMIT_WAIT_S = 5;
255
+
256
+ /**
257
+ * A mailbox of one session's own: an Orca Run, which is a name and an inbox and
258
+ * nothing else — it schedules nothing and runs nobody. The objective is what a
259
+ * person sees in `orca orchestration run-list`, so it says whose it is.
260
+ */
261
+ export const makeMailbox = (objective) =>
262
+ orca(['orchestration', 'run-create', '--objective', `obk ${objective}`]).run.id;
263
+
264
+ /**
265
+ * Take this process's turn at reading a mailbox.
266
+ *
267
+ * Orca fences a Run to one reader: a `check` from a caller bound elsewhere is
268
+ * refused with `consumer_fenced`, whatever the Run says. So a read binds first,
269
+ * every time — the kit's runs are short and the binding is the last one to have
270
+ * asked, not a lease anybody has to give back.
271
+ */
272
+ export const useMailbox = (id) => orca(['orchestration', 'run-use', '--id', id]).run;
273
+
274
+ /** Queue one message. `to` and `from` are mailboxes, written `run:<id>`. */
275
+ export function postMessage({ to, from, subject, body, type = 'status', thread }) {
276
+ const args = ['orchestration', 'send', '--to', to, '--subject', subject, '--body', body, '--type', type];
277
+ if (from !== undefined) args.push('--from', from);
278
+ if (thread !== undefined) args.push('--thread-id', thread);
279
+ return orca(args).message;
280
+ }
281
+
282
+ /**
283
+ * What is waiting in a mailbox: the oldest batch that has not been
284
+ * acknowledged, or, peeking, whatever is unread without touching it.
285
+ */
286
+ export const readMailbox = (id, { peek = false } = {}) =>
287
+ orca(['orchestration', 'check', '--run', id, ...(peek ? ['--peek'] : [])]);
288
+
289
+ /** Say a batch has been read, so the next check brings the one after it. */
290
+ export const ackMailbox = (id, delivery) =>
291
+ orca(['orchestration', 'check', '--run', id, '--ack', delivery]);
package/src/pause.js ADDED
@@ -0,0 +1,58 @@
1
+ // `obk pause` and `obk unpause`: stop a bot, or one of its sessions, for now,
2
+ // and bring it back as it was.
3
+ //
4
+ // A pause is a mark in the bot's `bot.yaml` and the bot's tabs closed. The
5
+ // mark is what `up` reads to leave it closed, and the book keeps the
6
+ // conversation each session was in, so an unpause is the mark taken off and
7
+ // the ordinary `up`, which resumes it (ADR 0002).
8
+ //
9
+ // The closing is restart's, with restart's rules: only the tabs the book names,
10
+ // one at a time by their own handles, and none at all when one of them holds a
11
+ // conversation the book could not name again.
12
+
13
+ import { realpathSync } from 'node:fs';
14
+
15
+ import { botDir, markPaused, readBot } from './bot.js';
16
+ import { closeTabs, tabsToClose } from './restart.js';
17
+ import { BOT_FATHER, botsNamed, bringUp, sessionsOf } from './up.js';
18
+
19
+ /**
20
+ * Pause the bot `bot`, or its session `session`. Returns
21
+ * `{ bot, session, changed, closed }`: whether the mark was new, and one entry
22
+ * per tab it closed.
23
+ */
24
+ export async function pauseSessions(bots, { bot, session }) {
25
+ const home = fleetMember(bots, bot, 'pause');
26
+ const sessions = sessionsOf(readBot(home, bot), session);
27
+
28
+ // Judged before anything is written or closed, so a refusal leaves both as
29
+ // they were.
30
+ const going = tabsToClose(bots, bot, home, sessions);
31
+ const changed = markPaused(bots, bot, session, true);
32
+ const closed = await closeTabs(home, going, bots, bot);
33
+ return { bot, session, changed, closed };
34
+ }
35
+
36
+ /**
37
+ * Take the pause off the bot `bot`, or its session `session`, and bring it up.
38
+ * Returns `{ bot, session, changed, ...what up answered }`.
39
+ */
40
+ export async function unpauseSessions(bots, { bot, session }) {
41
+ const home = fleetMember(bots, bot, 'unpause');
42
+ sessionsOf(readBot(home, bot), session);
43
+
44
+ const changed = markPaused(bots, bot, session, false);
45
+ return { bot, session, changed, ...(await bringUp(bots, { bot, session })) };
46
+ }
47
+
48
+ /**
49
+ * The bot's home, once it is certain the bot is there and is not Bot Father,
50
+ * which runs the fleet and is the one the user asks to bring anything back.
51
+ */
52
+ export function fleetMember(bots, bot, verb) {
53
+ if (bot === BOT_FATHER) {
54
+ throw new Error(`${BOT_FATHER} runs the fleet, and the kit does not ${verb} it: it is where you ask for everything else.`);
55
+ }
56
+ botsNamed(bots, bot);
57
+ return realpathSync(botDir(bots, bot));
58
+ }
package/src/record.js ADDED
@@ -0,0 +1,209 @@
1
+ // What a harness says when a session starts, and what the kit does with it.
2
+ //
3
+ // Both harnesses run the kit's hook (src/hooks.js) and hand it the session id
4
+ // they are running under. That is how the book stays the authority for session
5
+ // ids (ADR 0002) without the kit guessing from transcripts.
6
+ //
7
+ // Which session it is comes from the tab: Orca puts the tab id in the
8
+ // environment of everything running in it, and the book says which session owns
9
+ // which tab. Sessions of one bot share a folder, so the folder alone could not
10
+ // tell them apart — the tab can.
11
+
12
+ import { realpathSync } from 'node:fs';
13
+ import path from 'node:path';
14
+ import { spawnSync } from 'node:child_process';
15
+
16
+ import { forgetClaimed, readBook, rememberSession, sessionIdsIn, updateBook, withUnclaimed } from './book.js';
17
+ import { botDir, readBot } from './bot.js';
18
+ import { conversationsIn } from './conversations.js';
19
+ import { harnessOf, SHELL_ENV, startPrompt, workDirOf } from './launch.js';
20
+
21
+ /** Where Orca says which tab a program is running in (tech notes, section 1). */
22
+ export const TAB_ENV = 'ORCA_TAB_ID';
23
+
24
+ /**
25
+ * The launch line says which shell it ran in, and that is what tells a report
26
+ * from the session itself from a report by something the session started: a tab
27
+ * holds one session — the harness the kit typed in, the shell's own child — and
28
+ * everything started inside the tab inherits the tab's id without being it.
29
+ */
30
+ export { SHELL_ENV };
31
+
32
+ /** What a harness calls a session the user cleared, when it has a word for it. */
33
+ const CLEARED = 'clear';
34
+
35
+ /**
36
+ * Write down the session a harness reports, and answer with what the harness is
37
+ * to read back, or undefined when there is nothing to say to it.
38
+ *
39
+ * A cleared session is told its duty again, because the start prompt is the
40
+ * only thing that tells one session of a bot from another (PRD 6.4). A session
41
+ * that started or resumed has already been told, on the line that launched it.
42
+ *
43
+ * Two things say a session was cleared, and either is enough.
44
+ *
45
+ * The harness's own word, when it has one: Claude Code calls a `/clear` a clear.
46
+ * Codex has none to give — it calls its `/new` a startup (proved live, tech
47
+ * notes section 3) — so the second is the book: an id that is not the one this
48
+ * tab had is a conversation the kit did not start, and the only thing that
49
+ * starts one without the kit is the user beginning a new one.
50
+ *
51
+ * Neither can hand the duty over twice. `up` puts it on the launch line only
52
+ * when the book holds no id for the session, so a session with an id in the
53
+ * book was told nothing by the line that started it; and a launch is never a
54
+ * clear.
55
+ *
56
+ * Nothing here knows about Orca beyond the tab id it is handed: the hook runs
57
+ * inside the user's session, and a session is not a place to make calls from.
58
+ */
59
+ export async function recordSession(bots, name, said, tabId, shellPid) {
60
+ // Without the tab there is no telling which session this is, and a guess
61
+ // would be the one thing this slice must never do.
62
+ if (typeof tabId !== 'string' || tabId === '') return undefined;
63
+
64
+ const id = said?.session_id;
65
+ if (typeof id !== 'string' || id === '') return undefined;
66
+
67
+ const home = realpathSync(botDir(bots, name));
68
+ // A tab no session of this bot owns: Bot Father's ops tab, or somebody else's
69
+ // work. The kit writes nothing down about tabs it does not keep.
70
+ if (sessionAt(readBook(home), tabId) === undefined) return undefined;
71
+
72
+ // And a report from something the session started rather than from the
73
+ // session itself is not this session's news, whatever tab it ran in.
74
+ if (!startedTheSession(shellPid)) return undefined;
75
+
76
+ const bot = readBot(home, name);
77
+ // Asking the harness what it has on record reads a folder of files, so it is
78
+ // done before the lock is taken and never while it is held. It answers about
79
+ // this bot's folder, which every session of the bot shares — so it can say
80
+ // that a conversation nobody claims exists, and never whose it is.
81
+ const unclaimed = unclaimedFor(readBook(home), home, bot, tabId, id);
82
+
83
+ let cleared = false;
84
+ let told;
85
+
86
+ await updateBook(home, (book) => {
87
+ const found = sessionAt(book, tabId);
88
+ // The book is read again under the lock, so a session that went away while
89
+ // this waited is a session this no longer has anything to say about.
90
+ if (found === undefined) return book;
91
+
92
+ const [session, was] = found;
93
+ // The harness's own word for it, or — for the harness that has no word — an
94
+ // id that is not the one this tab had, which is the same thing said twice.
95
+ cleared = said.source === CLEARED || (was.session !== undefined && was.session !== id);
96
+ // A first report with something unclaimed beside it is the third case: the
97
+ // kit cannot tell whether this conversation is the one the launch line spoke
98
+ // to or a later one, and a session left without its duty is the failure this
99
+ // slice exists to prevent, so it is told again.
100
+ const uncertain = was.session === undefined && unclaimed.length > 0;
101
+ told = session;
102
+
103
+ book.sessions[session] = withUnclaimed(rememberSession(was, id, said.source), unclaimed);
104
+ forgetClaimed(book);
105
+ if (uncertain) cleared = true;
106
+ return book;
107
+ });
108
+
109
+ if (!cleared || told === undefined) return undefined;
110
+
111
+ const settings = bot.sessions.find((entry) => entry.name === told);
112
+ if (settings === undefined) return undefined;
113
+
114
+ const duty = startPrompt(settings, { home, workDir: workDirOf(settings, home) });
115
+ if (duty === undefined) return undefined;
116
+
117
+ // The harness's own way of putting text into a session, answered on the event
118
+ // it asked about: what it reads back is added to what the session knows.
119
+ return { hookSpecificOutput: { hookEventName: said.hook_event_name, additionalContext: duty } };
120
+ }
121
+
122
+ /** The session of this bot that owns `tabId`, with what the book holds for it. */
123
+ const sessionAt = (book, tabId) =>
124
+ Object.entries(book.sessions).find(([, entry]) => entry?.tab === tabId);
125
+
126
+ /**
127
+ * The conversations of this bot home that nobody claims, for the session that
128
+ * owns `tabId`: started since the kit put a harness in that tab, not the one
129
+ * reporting now, and not a conversation any session of the bot holds or has
130
+ * held.
131
+ *
132
+ * It says that such a conversation exists. It never says whose it is, and the
133
+ * kit never decides: a bot's sessions share one folder, and so does every
134
+ * harness they start inside themselves, so the folder cannot tell them apart and
135
+ * the time they started cannot either. The review proved both — a `codex exec`
136
+ * child's conversation taken for its parent's, and two sessions reporting in
137
+ * reverse order taking each other's.
138
+ *
139
+ * Nothing either harness writes down ties a conversation that has already ended
140
+ * to the process that had it: Claude Code's registry maps a live pid to the
141
+ * conversation it is having now and keeps no earlier one, and Codex records no
142
+ * pid at all (tech notes, sections 2 and 3). So this is as far as evidence goes,
143
+ * and the rest is for a person or Bot Father to settle.
144
+ */
145
+ function unclaimedFor(book, home, bot, tabId, id) {
146
+ const found = sessionAt(book, tabId);
147
+ if (found === undefined) return [];
148
+
149
+ const [session, was] = found;
150
+ if (typeof was.launched !== 'string') return [];
151
+
152
+ const settings = bot.sessions.find((entry) => entry.name === session);
153
+ if (settings === undefined) return [];
154
+
155
+ const claimed = sessionIdsIn(book);
156
+ return conversationsIn(harnessOf(settings, bot.harness), home, was.launched)
157
+ .filter((one) => one.id !== id && !claimed.has(one.id))
158
+ .map((one) => one.id);
159
+ }
160
+
161
+ /**
162
+ * Whether the harness that ran this hook is the session's own — the one the kit
163
+ * typed into the tab — rather than a harness started inside the session.
164
+ *
165
+ * Proved live on both harnesses (tech notes, sections 2 and 3): a hook's process
166
+ * is the harness's own child, or the child of the shell the harness ran the hook
167
+ * command through. Either way the harness itself is the process just above, and
168
+ * the session's harness is the child of the shell named in the launch line. A
169
+ * harness started inside the session sits further down a longer chain, under
170
+ * whatever shell the agent ran it from, so it can never answer this.
171
+ *
172
+ * Anything it cannot establish is a no: a report that cannot be shown to be the
173
+ * session's own is not written down.
174
+ */
175
+ export function startedTheSession(shellPid, pid = process.pid) {
176
+ if (!/^[0-9]+$/.test(String(shellPid ?? ''))) return false;
177
+ const shell = Number(shellPid);
178
+
179
+ const tree = processTree();
180
+ if (tree === undefined) return false;
181
+
182
+ const mine = tree.get(pid);
183
+ const parent = mine === undefined ? undefined : tree.get(mine.ppid);
184
+ if (parent === undefined) return false;
185
+
186
+ if (parent.ppid === shell) return true;
187
+ if (!isShell(parent.command)) return false;
188
+
189
+ const above = tree.get(parent.ppid);
190
+ return above !== undefined && above.ppid === shell;
191
+ }
192
+
193
+ /** Every process on the machine, by pid: who its parent is and what it runs. */
194
+ function processTree() {
195
+ const listed = spawnSync('ps', ['-Ao', 'pid=,ppid=,comm='], { encoding: 'utf8' });
196
+ if (listed.error !== undefined || listed.status !== 0) return undefined;
197
+
198
+ const tree = new Map();
199
+ for (const line of listed.stdout.split('\n')) {
200
+ const said = line.trim().match(/^([0-9]+)\s+([0-9]+)\s+(.*)$/);
201
+ if (said !== null) tree.set(Number(said[1]), { ppid: Number(said[2]), command: said[3] });
202
+ }
203
+ return tree.size === 0 ? undefined : tree;
204
+ }
205
+
206
+ /** The shells a harness may run a hook command through. */
207
+ const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh']);
208
+
209
+ const isShell = (command) => SHELLS.has(path.basename(command ?? ''));