@assuredloop/orca-bot-kit 0.2.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 (49) hide show
  1. package/README.md +15 -12
  2. package/SETUP.md +7 -2
  3. package/package.json +1 -1
  4. package/rules/finishing.md +3 -0
  5. package/rules/mail.md +3 -3
  6. package/rules/scope.md +4 -2
  7. package/rules/tests-first.md +6 -4
  8. package/skills/obk-arch/LICENSES.md +7 -0
  9. package/skills/obk-arch/NOTICE.md +113 -5
  10. package/skills/obk-arch/SKILL.md +221 -10
  11. package/skills/obk-bot-building/NOTICE.md +2 -2
  12. package/skills/obk-bot-building/SKILL.md +85 -8
  13. package/skills/obk-debugging/SKILL.md +30 -15
  14. package/skills/obk-decision-memo/NOTICE.md +1 -1
  15. package/skills/obk-finops/NOTICE.md +1 -1
  16. package/skills/obk-finops/SKILL.md +6 -4
  17. package/skills/obk-fleet-review/SKILL.md +42 -5
  18. package/skills/obk-grilling/NOTICE.md +11 -3
  19. package/skills/obk-grilling/SKILL.md +11 -7
  20. package/skills/obk-grooming/SKILL.md +5 -2
  21. package/skills/obk-handoff/NOTICE.md +1 -1
  22. package/skills/obk-recall/NOTICE.md +1 -1
  23. package/skills/obk-researching/NOTICE.md +1 -1
  24. package/skills/obk-researching/SKILL.md +19 -9
  25. package/skills/obk-reviewing/SKILL.md +4 -1
  26. package/skills/obk-tdd/SKILL.md +54 -23
  27. package/skills/obk-teach/NOTICE.md +2 -2
  28. package/skills/obk-why/NOTICE.md +1 -1
  29. package/skills/obk-why/SKILL.md +21 -10
  30. package/src/book.js +2 -2
  31. package/src/bot.js +1 -1
  32. package/src/cli.js +59 -20
  33. package/src/groom.js +5 -3
  34. package/src/health.js +2 -2
  35. package/src/hooks.js +73 -18
  36. package/src/init.js +3 -3
  37. package/src/launch.js +28 -5
  38. package/src/message.js +36 -14
  39. package/src/orca.js +95 -24
  40. package/src/pause.js +11 -6
  41. package/src/record.js +1 -1
  42. package/src/restart.js +6 -5
  43. package/src/retire.js +2 -2
  44. package/src/roster.js +1 -1
  45. package/src/rules.js +2 -2
  46. package/src/skills.js +7 -6
  47. package/src/sources.js +4 -3
  48. package/src/up.js +40 -15
  49. package/src/usage.js +2 -2
package/src/hooks.js CHANGED
@@ -1,4 +1,4 @@
1
- // The kit's own hook, in the bot's folder and nowhere else (ADR 0010).
1
+ // The kit's own hook, in the bot's folder and nowhere else (ADR 0020).
2
2
  //
3
3
  // Both harnesses read a hooks file out of the folder a session starts in, and
4
4
  // every session starts at its bot home, so the bot folder is where the kit's
@@ -15,7 +15,7 @@ import path from 'node:path';
15
15
  import { isDeepStrictEqual } from 'node:util';
16
16
 
17
17
  import { leadsOutside } from './bot.js';
18
- import { shellWord } from './launch.js';
18
+ import { ownCli, shellWord } from './launch.js';
19
19
 
20
20
  /** Where each harness reads a project's hooks, inside the bot home. */
21
21
  const HOOK_FILE = { claude: '.claude/settings.json', codex: '.codex/hooks.json' };
@@ -31,17 +31,16 @@ const EVENT = 'SessionStart';
31
31
  /** How long the harness gives the hook before it goes on without it. */
32
32
  const TIMEOUT = 10;
33
33
 
34
- /** The kit's own hook among whatever else the user keeps in the file. */
35
- const KIT_COMMAND = 'obk session record';
36
-
37
34
  /**
38
- * What the hook runs. `|| true` and a quiet stderr because a hook must never
39
- * disturb the session it fires in (ADR 0010): a kit that is not installed, or a
40
- * book that cannot be written, leaves the session alone and the health check
41
- * finds the stale book later.
35
+ * What the hook runs: the CLI that wrote it, by its own path, so the session
36
+ * reports to the kit that set it up and not to whatever `obk` PATH finds in the
37
+ * harness's shell (#220). `|| true` and a quiet stderr because a hook must never
38
+ * disturb the session it fires in (ADR 0020): a kit that is not there, or a book
39
+ * that cannot be written, leaves the session alone and the health check finds
40
+ * the stale book later.
42
41
  */
43
- export const hookCommand = (bots, bot) =>
44
- `${KIT_COMMAND} --bots ${shellWord(bots)} --bot ${shellWord(bot)} 2>/dev/null || true`;
42
+ export const hookCommand = (bots, bot, cli = ownCli()) =>
43
+ `${shellWord(cli)} session record --bots ${shellWord(bots)} --bot ${shellWord(bot)} 2>/dev/null || true`;
45
44
 
46
45
  /**
47
46
  * Make sure the bot at `home` has the kit's SessionStart hook for `harness`.
@@ -81,7 +80,7 @@ export function installHook(home, harness, { bots, bot }) {
81
80
  *
82
81
  * The same question `installHook` asks, without the writing. It matters more
83
82
  * than it looks: the hook is the whole of how the book learns which
84
- * conversation a session is running as (ADR 0002), and when it is not there
83
+ * conversation a session is running as (ADR 0012), and when it is not there
85
84
  * nothing else says so — the book simply stops being true.
86
85
  */
87
86
  export function hookTrouble(home, harness, { bots, bot }) {
@@ -95,7 +94,22 @@ export function hookTrouble(home, harness, { bots, bot }) {
95
94
  let settings;
96
95
  try {
97
96
  settings = readSettings(file);
98
- wanted = { ...settings, hooks: withKitHook(settings.hooks, file, { type: 'command', command: hookCommand(bots, bot), timeout: TIMEOUT }) };
97
+ // The hook in the file is judged by what it runs, not by whether this is
98
+ // the install that wrote it. A bot made before the kit named itself by path
99
+ // runs the bare `obk`, and one another install wrote runs that install: both
100
+ // still reach a kit, and the next `obk up` writes this one's. So the line is
101
+ // checked against the program already in it, and only one that names a CLI
102
+ // that is not there any more is wrong — `|| true` would keep that quiet, and
103
+ // the book would stop being true with nothing to say why.
104
+ const running = kitProgramIn(settings);
105
+ if (running !== undefined && running !== BARE && !existsSync(running)) {
106
+ return {
107
+ where: file,
108
+ says: `${file} holds the kit's session hook, but it runs ${running}, which is not there any more. ${stale} obk up puts back one that runs the kit you have.`,
109
+ };
110
+ }
111
+ const program = running ?? ownCli();
112
+ wanted = { ...settings, hooks: withKitHook(settings.hooks, file, { type: 'command', command: hookCommand(bots, bot, program), timeout: TIMEOUT }) };
99
113
  } catch (error) {
100
114
  return { where: file, says: `${error.message} ${stale}` };
101
115
  }
@@ -110,7 +124,7 @@ export function hookTrouble(home, harness, { bots, bot }) {
110
124
  /**
111
125
  * What to say about a hooks file that a link takes out of the bot folder, or
112
126
  * undefined when it stays in. The kit writes its hook in the bot folder and
113
- * nowhere else (ADR 0010); through a link like that it would be writing the
127
+ * nowhere else (ADR 0020); through a link like that it would be writing the
114
128
  * user's own settings, so it does not, and says why.
115
129
  */
116
130
  function outside(home, file) {
@@ -169,14 +183,55 @@ function withKitHook(hooks, file, mine) {
169
183
  /**
170
184
  * The kit's own hook entry, wherever it sits and whatever sits beside it: a
171
185
  * command of exactly the shape `hookCommand` writes, for any bots folder and
172
- * bot, so an entry written before the folder moved is still the kit's. A line
173
- * of the user's that only mentions the command is theirs (PRD 6.5).
186
+ * bot, run by a kit — the bare `obk` a bot made before #220 still has, this
187
+ * install, or another — so an entry written before the folder moved or the kit
188
+ * changed is still the kit's, and is rewritten in place rather than joined by a
189
+ * second. A line of the user's that only mentions the command, or runs it with
190
+ * a program of their own, is theirs (PRD 6.5).
174
191
  */
175
- const isKitHook = (hook) => typeof hook?.command === 'string' && KIT_HOOK.test(hook.command);
192
+ const isKitHook = (hook) => kitProgramOf(hook?.command) !== undefined;
176
193
 
177
194
  /** One word as `shellWord` writes it: bare, or single-quoted with `'\''` inside. */
178
195
  const WORD = String.raw`(?:[A-Za-z0-9,._+:@%/=-]+|'(?:[^']|'\\'')*')`;
179
- const KIT_HOOK = new RegExp(String.raw`^${KIT_COMMAND} --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
196
+ const KIT_HOOK = new RegExp(String.raw`^(${WORD}) session record --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
197
+
198
+ /** The program a bot made before the kit named itself by path runs. */
199
+ const BARE = 'obk';
200
+
201
+ /**
202
+ * Whether a program is a kit: the bare name, an install's `obk` wherever it
203
+ * lives, or a checkout's `src/cli.js` — the only two ways `ownCli` is reached.
204
+ */
205
+ const isKitCli = (program) => path.basename(program) === BARE || program.endsWith('/src/cli.js');
206
+
207
+ /** The program a hook command of the kit's runs, unquoted, or undefined for any other command. */
208
+ function kitProgramOf(command) {
209
+ const found = typeof command === 'string' ? KIT_HOOK.exec(command) : null;
210
+ if (found === null) return undefined;
211
+ const program = unquoted(found[1]);
212
+ return isKitCli(program) ? program : undefined;
213
+ }
214
+
215
+ /**
216
+ * What the kit's hook in these settings runs, as a path or the bare name, or
217
+ * undefined when there is none. Read back out of its shell quoting, so it can be
218
+ * looked for on disk.
219
+ */
220
+ function kitProgramIn(settings) {
221
+ for (const groups of Object.values(settings.hooks ?? {})) {
222
+ if (!Array.isArray(groups)) continue;
223
+ for (const group of groups) {
224
+ for (const hook of Array.isArray(group?.hooks) ? group.hooks : []) {
225
+ const program = kitProgramOf(hook?.command);
226
+ if (program !== undefined) return program;
227
+ }
228
+ }
229
+ }
230
+ return undefined;
231
+ }
232
+
233
+ /** A word as `shellWord` wrote it, read back to what it stands for. */
234
+ const unquoted = (word) => (word.startsWith("'") ? word.slice(1, -1).replaceAll(`'\\''`, "'") : word);
180
235
 
181
236
  /**
182
237
  * What the file says, or nothing when there is no file yet. A file the kit
package/src/init.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // Creates the user's bots folder: their own git repo, seeded with the files the
2
2
  // kit reads, and Bot Father. The kit puts none of its own code in here — kit
3
- // skills are linked from the installed package (ADR 0004).
3
+ // skills are linked from the installed package (ADR 0014).
4
4
  //
5
5
  // Seeding only ever adds what is missing. The user edits these files, by hand
6
6
  // or through Bot Father, so a re-run must never write over them.
@@ -15,7 +15,7 @@ import { RECORD, SKILL_DIRS } from './skills.js';
15
15
 
16
16
  /**
17
17
  * What the kit makes on this machine, and what the repo therefore does not
18
- * carry (ADR 0004, PRD 6.3).
18
+ * carry (ADR 0014, PRD 6.3).
19
19
  *
20
20
  * A bot's skills are symlinks into wherever the kit and the user's skill
21
21
  * sources are installed on the machine that made them, so a clone somewhere
@@ -126,7 +126,7 @@ const MANAGEMENT_SKILLS = ['obk-bot-building', 'obk-finops', 'obk-fleet-review',
126
126
  const BOT_FATHER_YAML = 'bots/bot-father/bot.yaml';
127
127
 
128
128
  /** The management session every Bot Father has: the tab you talk to it in. */
129
- const DAILY_SESSION = 'daily';
129
+ export const DAILY_SESSION = 'daily';
130
130
 
131
131
  const seeds = (harness) => [
132
132
  ['.gitignore', GITIGNORE],
package/src/launch.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // What a session is started with: the one line typed into its tab, which brings
2
2
  // the harness up and carries the session's start prompt with it.
3
3
  //
4
- // The flag mapping is PRD 6.4 and ADR 0005, re-checked against the installed
4
+ // The flag mapping is PRD 6.4 and ADR 0015, re-checked against the installed
5
5
  // CLIs (Claude Code 2.1.278, Codex 0.155.1). Every session is given its
6
6
  // approval level explicitly, so a user's own global harness defaults cannot
7
7
  // leak into a bot. The kit names no model: a session that sets none gets the
@@ -12,6 +12,7 @@
12
12
 
13
13
  import { readFileSync } from 'node:fs';
14
14
  import path from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
15
16
 
16
17
  /** The approval levels, and what each one is called on each harness. */
17
18
  const APPROVAL = {
@@ -38,7 +39,7 @@ export const bypassFlags = (harness) => APPROVAL[harness]['dangerously-skip'];
38
39
 
39
40
  export const HARNESSES = Object.keys(APPROVAL);
40
41
 
41
- /** The level a session that names none runs at (ADR 0005). */
42
+ /** The level a session that names none runs at (ADR 0015). */
42
43
  export const DEFAULT_APPROVAL = 'auto';
43
44
 
44
45
  /** A setting the user left out. An empty string is one too: it means the harness's own. */
@@ -49,7 +50,7 @@ export const harnessOf = (session, botHarness) => (set(session.harness) ? sessio
49
50
 
50
51
  /**
51
52
  * 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
+ * session writes to (ADR 0018). It goes on the launch line as `-n`, and it is
53
54
  * re-applied every time the session is started: a resume keeps the name by
54
55
  * itself (tech notes, section 2), and the kit does not depend on that.
55
56
  */
@@ -161,7 +162,7 @@ export function launchCommand(session, { harness, home, workDir, prompt, promptF
161
162
 
162
163
  if (harness === 'claude') {
163
164
  // The name is the address other Claude sessions write to, so it goes on
164
- // every launch line, a resume included (ADR 0008).
165
+ // every launch line, a resume included (ADR 0018).
165
166
  if (address !== undefined) words.push('-n', address);
166
167
  // The context window rides on the model name: `sonnet[1m]`.
167
168
  if (set(session.model)) words.push('--model', set(session.context) ? `${session.model}[${session.context}]` : session.model);
@@ -174,7 +175,7 @@ export function launchCommand(session, { harness, home, workDir, prompt, promptF
174
175
  if (set(session.effort)) words.push('-c', `model_reasoning_effort=${session.effort}`);
175
176
  if (set(session.context)) words.push('-c', `model_context_window=${session.context}`);
176
177
  // 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
+ // started in, and nowhere else (ADR 0015), so a work dir outside the bot
178
179
  // home has to be named.
179
180
  if (workDir !== undefined && !inside(home, workDir)) words.push('--add-dir', workDir);
180
181
  }
@@ -184,6 +185,7 @@ export function launchCommand(session, { harness, home, workDir, prompt, promptF
184
185
  // starts — the shell's child — from anything the session starts later inside
185
186
  // the tab. `$$` is the shell's, and it is not quoted for that reason.
186
187
  `${SHELL_ENV}=$$`,
188
+ `${CLI_ENV}=${quoted(ownCli())}`,
187
189
  ...words.map(quoted),
188
190
  ...extraWords(session.extra_args),
189
191
  ...resumeWords(harness, resume),
@@ -274,3 +276,24 @@ const quoted = (word) => (/^[A-Za-z0-9,._+:@%/=-]+$/.test(word) ? word : `'${wor
274
276
 
275
277
  /** The same, for anything else of the kit's that has to build a shell line. */
276
278
  export const shellWord = quoted;
279
+
280
+ /**
281
+ * The CLI that is running, by the path it was started by: the kit calls itself
282
+ * back with this, never with the bare name `obk`, because the bare name is
283
+ * whatever PATH says, and PATH is not the kit's to decide (#220). A user's
284
+ * install is started through npm's bin link, and the link is kept rather than
285
+ * resolved, so the path survives an upgrade of the package; a system test that
286
+ * runs a checkout's `src/cli.js` gets that checkout. What an environment
287
+ * variable claims is not asked: the kit says who it is from how it was started.
288
+ */
289
+ export const ownCli = () => (process.argv[1] === undefined
290
+ // Nothing started as a script, as with `node -e`: the kit's own entry file.
291
+ ? fileURLToPath(new URL('./cli.js', import.meta.url))
292
+ : path.resolve(process.argv[1]));
293
+
294
+ /**
295
+ * Where the launch line hands the session the CLI that started it, so a bot's
296
+ * own commands can call the same one. A plain variable, because a harness's
297
+ * shell keeps those where it rearranges PATH (#220).
298
+ */
299
+ export const CLI_ENV = 'OBK_CLI';
package/src/message.js CHANGED
@@ -1,4 +1,4 @@
1
- // `obk message`: one session writing to another (PRD 6.9, ADR 0008).
1
+ // `obk message`: one session writing to another (PRD 6.9, ADR 0018).
2
2
  //
3
3
  // Two roads, and the bot never picks. Claude to Claude in the same approval
4
4
  // class is the harness's own messaging, which no command line can send for it —
@@ -20,8 +20,8 @@ import path from 'node:path';
20
20
 
21
21
  import { readBook } from './book.js';
22
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';
23
+ import { harnessOf, ownCli, reachesMail, shellWord } from './launch.js';
24
+ import { ackMailbox, harnessInTab, postMessage, readMailbox, tabs, typeIntoTab, useMailbox } from './orca.js';
25
25
 
26
26
  /**
27
27
  * How much of a message travels as itself. Above this it is written to a file
@@ -147,7 +147,7 @@ export function sendMessage(bots, { to: target, from: sender, tab, subject, text
147
147
  return {
148
148
  ...answer,
149
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}`,
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
151
  };
152
152
  }
153
153
 
@@ -192,10 +192,15 @@ export function checkMail(bots, { bot: botName, session: sessionName, tab, peek
192
192
  };
193
193
  }
194
194
 
195
- useMailbox(who.mailbox);
196
- const found = readMailbox(who.mailbox, { peek });
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 });
197
202
  const messages = (found.messages ?? []).map((message) => asMessage(bots, message));
198
- if (!peek && found.deliveryId !== undefined && messages.length > 0) ackMailbox(who.mailbox, found.deliveryId);
203
+ if (!peek && found.deliveryId !== undefined && messages.length > 0) ackMailbox(who.mailbox, found.deliveryId, handle);
199
204
 
200
205
  return { bots, bot: who.bot, session: who.session, mailbox: who.mailbox, read: !peek, messages };
201
206
  }
@@ -295,9 +300,10 @@ const stamp = () => new Date().toISOString().replaceAll(':', '-').replace('.', '
295
300
  *
296
301
  * Nothing in the mailbox reaches a running harness by itself, and a typed line
297
302
  * 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
303
+ * it is having. A tab with no harness in it is not typed into at all — there is
299
304
  * 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.
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.
301
307
  *
302
308
  * Nor is a tab with something on screen waiting to be answered. A line typed
303
309
  * into one of those is not a message: it is an answer to whatever question is
@@ -318,13 +324,29 @@ function nudge(to, from, subject) {
318
324
  const live = tabs(to.home).find((tab) => tab.tabId === to.tab);
319
325
  if (live === undefined) return { nudged: false };
320
326
 
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 };
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.
324
346
 
325
347
  typeIntoTab(
326
348
  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}`,
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)}`,
328
350
  );
329
351
  return { nudged: true };
330
352
  } catch (error) {
@@ -377,4 +399,4 @@ function whyNotReachable(bot, session, harness, held) {
377
399
  }
378
400
 
379
401
  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}`;
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)}`;
package/src/orca.js CHANGED
@@ -1,4 +1,4 @@
1
- // Every call the kit makes to Orca goes through here (ADR 0001). Orca's CLI
1
+ // Every call the kit makes to Orca goes through here (ADR 0011). 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
  //
@@ -192,25 +192,83 @@ export const retitleTab = (handle, title) =>
192
192
  orca(['terminal', 'rename', '--terminal', handle, '--title', title]).rename;
193
193
 
194
194
  /**
195
- * Whether a TUI is running in the tab, and what it is waiting on if Orca says.
195
+ * What can be learned about the harness in a tab: what Orca says, and who is
196
+ * in front of the tab's terminal.
196
197
  *
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.
198
+ * Orca's answers alone do not tell a harness from a shell (tech notes, section
199
+ * 1, #232). `tui-idle` refuses a busy harness with `timeout` just as it refuses
200
+ * a shell, and a shell Codex quit to answers `ok`. `agentIdentity` comes late
201
+ * and can outlast the harness by minutes. So the answer the caller acts on is
202
+ * `front`: `program` when something other than the tab's shell holds its
203
+ * terminal, with `command` its name, `shell` when the shell does, and
204
+ * undefined when that cannot be read, with `unreadable` saying why. `answered` (an `ok` from `tui-idle`) and
205
+ * `agent` are Orca's own hints, for a caller that has nothing better.
206
+ * `blockedReason` is Orca saying something on screen wants answering, which is
207
+ * for the caller to deal with, not the kit.
203
208
  */
204
- export function tuiInTab(handle, timeoutMs) {
209
+ export function harnessInTab(handle, timeoutMs) {
205
210
  const args = ['terminal', 'wait', '--terminal', handle, '--for', 'tui-idle', '--timeout-ms', String(timeoutMs)];
206
211
  const answer = ask(args);
207
212
 
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 };
213
+ // Out of time means nothing went idle, busy or absent alike. That is an
214
+ // answer, not a breakdown.
215
+ if (answer.ok !== true && answer.error?.code !== 'timeout') {
211
216
  throw new Error(`Orca refused ${args.join(' ')}: ${answer.error?.message ?? 'no reason given'}`);
212
217
  }
213
- return { running: true, blockedReason: answer.result?.wait?.blockedReason };
218
+
219
+ const shown = orca(['terminal', 'show', '--terminal', handle]).terminal;
220
+ const agent = typeof shown?.agentIdentity === 'string' && shown.agentIdentity !== '' ? shown.agentIdentity : undefined;
221
+ return {
222
+ answered: answer.ok === true,
223
+ blockedReason: answer.result?.wait?.blockedReason,
224
+ agent,
225
+ ...frontOf(shown?.ptyId),
226
+ };
227
+ }
228
+
229
+ /** The `ps` this run reads. OBK_PS overrides it, as OBK_ORCA does Orca. */
230
+ const psCli = () => process.env.OBK_PS || '/bin/ps';
231
+
232
+ /**
233
+ * Who holds the terminal of the pane `ptyId`: `{ front: 'shell' }`,
234
+ * `{ front: 'program', command }` with the name of the process leading the
235
+ * group in front, or `{ unreadable: <why> }`.
236
+ *
237
+ * Orca gives the pane's pid in `diagnostics memory` and nowhere else, and `ps`
238
+ * gives that pid's terminal's foreground process group (ADR 0011).
239
+ * On macOS the pane is `login` with the shell as its child, so the shell is in
240
+ * front when the group is the pane's own or that of a child of a `login` pane.
241
+ * `diagnostics memory` is a diagnostics command and may change, so everything
242
+ * here ends in "cannot tell" rather than in an error or a guess.
243
+ */
244
+ function frontOf(ptyId) {
245
+ let pane;
246
+ try {
247
+ pane = orca(['diagnostics', 'memory']).worktrees
248
+ .flatMap((worktree) => worktree.sessions ?? [])
249
+ .find((session) => session.sessionId === ptyId)?.pid;
250
+ } catch (error) {
251
+ return { unreadable: `Orca would not give the pane's pid: ${error.message}` };
252
+ }
253
+ if (!Number.isInteger(pane) || pane <= 0) return { unreadable: 'Orca gave no pid for its pane' };
254
+
255
+ const own = psLine(pane);
256
+ if (own === undefined) return { unreadable: `ps could not read its pane, pid ${pane}` };
257
+ if (own.tpgid === pane) return { front: 'shell' };
258
+
259
+ const front = psLine(own.tpgid);
260
+ if (front === undefined) return { unreadable: `ps could not read the process in front, pid ${own.tpgid}` };
261
+ const shell = front.ppid === pane && path.basename(own.comm) === 'login';
262
+ return shell ? { front: 'shell' } : { front: 'program', command: path.basename(front.comm) };
263
+ }
264
+
265
+ /** One process as `ps` gives it, read only: `{ ppid, tpgid, comm }`, or undefined. */
266
+ function psLine(pid) {
267
+ const asked = spawnSync(psCli(), ['-o', 'pid=,ppid=,tpgid=,comm=', '-p', String(pid)], { encoding: 'utf8' });
268
+ if (asked.error || asked.status !== 0) return undefined;
269
+ const line = /^\s*(\d+)\s+(\d+)\s+(-?\d+)\s+(.+?)\s*$/.exec(asked.stdout);
270
+ if (line === null) return undefined;
271
+ return { ppid: Number(line[2]), tpgid: Number(line[3]), comm: line[4] };
214
272
  }
215
273
 
216
274
  /**
@@ -257,19 +315,27 @@ const SUBMIT_WAIT_S = 5;
257
315
  * A mailbox of one session's own: an Orca Run, which is a name and an inbox and
258
316
  * nothing else — it schedules nothing and runs nobody. The objective is what a
259
317
  * person sees in `orca orchestration run-list`, so it says whose it is.
318
+ *
319
+ * Bound to `handle`, the session's own tab, from the moment it is made. Orca
320
+ * tells a Run's coordinator terminal about its mail and nobody else, and binds
321
+ * the caller when no `--from` is given: the tab that ran `obk up` would be told
322
+ * about every session it brought up, and would lose its own Run's binding, since
323
+ * one terminal holds one Run (tech notes, section 1).
260
324
  */
261
- export const makeMailbox = (objective) =>
262
- orca(['orchestration', 'run-create', '--objective', `obk ${objective}`]).run.id;
325
+ export const makeMailbox = (objective, handle) =>
326
+ orca(['orchestration', 'run-create', '--objective', `obk ${objective}`, '--from', handle]).run.id;
263
327
 
264
328
  /**
265
- * Take this process's turn at reading a mailbox.
329
+ * Take a turn at reading a mailbox, as the terminal `handle`.
266
330
  *
267
- * Orca fences a Run to one reader: a `check` from a caller bound elsewhere is
331
+ * Orca fences a Run to one reader: a `check` from a terminal bound elsewhere is
268
332
  * refused with `consumer_fenced`, whatever the Run says. So a read binds first,
269
333
  * 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.
334
+ * asked, not a lease anybody has to give back. Without a handle it binds this
335
+ * process's own terminal.
271
336
  */
272
- export const useMailbox = (id) => orca(['orchestration', 'run-use', '--id', id]).run;
337
+ export const useMailbox = (id, handle) =>
338
+ orca(['orchestration', 'run-use', '--id', id, ...(handle === undefined ? [] : ['--from', handle])]).run;
273
339
 
274
340
  /** Queue one message. `to` and `from` are mailboxes, written `run:<id>`. */
275
341
  export function postMessage({ to, from, subject, body, type = 'status', thread }) {
@@ -281,11 +347,16 @@ export function postMessage({ to, from, subject, body, type = 'status', thread }
281
347
 
282
348
  /**
283
349
  * What is waiting in a mailbox: the oldest batch that has not been
284
- * acknowledged, or, peeking, whatever is unread without touching it.
350
+ * acknowledged, or, peeking, whatever is unread without touching it. Read as
351
+ * the terminal `handle` when one is given, which is how a read works from a tab
352
+ * bound to another Run; otherwise as this process's own terminal.
285
353
  */
286
- export const readMailbox = (id, { peek = false } = {}) =>
287
- orca(['orchestration', 'check', '--run', id, ...(peek ? ['--peek'] : [])]);
354
+ export const readMailbox = (id, { peek = false, handle } = {}) =>
355
+ orca(['orchestration', 'check', '--run', id, ...as(handle), ...(peek ? ['--peek'] : [])]);
288
356
 
289
357
  /** 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]);
358
+ export const ackMailbox = (id, delivery, handle) =>
359
+ orca(['orchestration', 'check', '--run', id, ...as(handle), '--ack', delivery]);
360
+
361
+ /** The reader of a `check`: the terminal named, or this process's own. */
362
+ const as = (handle) => (handle === undefined ? [] : ['--terminal', handle]);
package/src/pause.js CHANGED
@@ -4,7 +4,7 @@
4
4
  // A pause is a mark in the bot's `bot.yaml` and the bot's tabs closed. The
5
5
  // mark is what `up` reads to leave it closed, and the book keeps the
6
6
  // conversation each session was in, so an unpause is the mark taken off and
7
- // the ordinary `up`, which resumes it (ADR 0002).
7
+ // the ordinary `up`, which resumes it (ADR 0012).
8
8
  //
9
9
  // The closing is restart's, with restart's rules: only the tabs the book names,
10
10
  // one at a time by their own handles, and none at all when one of them holds a
@@ -13,6 +13,7 @@
13
13
  import { realpathSync } from 'node:fs';
14
14
 
15
15
  import { botDir, markPaused, readBot } from './bot.js';
16
+ import { DAILY_SESSION } from './init.js';
16
17
  import { closeTabs, tabsToClose } from './restart.js';
17
18
  import { BOT_FATHER, botsNamed, bringUp, sessionsOf } from './up.js';
18
19
 
@@ -22,7 +23,7 @@ import { BOT_FATHER, botsNamed, bringUp, sessionsOf } from './up.js';
22
23
  * per tab it closed.
23
24
  */
24
25
  export async function pauseSessions(bots, { bot, session }) {
25
- const home = fleetMember(bots, bot, 'pause');
26
+ const home = fleetMember(bots, bot, 'pause', session);
26
27
  const sessions = sessionsOf(readBot(home, bot), session);
27
28
 
28
29
  // Judged before anything is written or closed, so a refusal leaves both as
@@ -38,7 +39,7 @@ export async function pauseSessions(bots, { bot, session }) {
38
39
  * Returns `{ bot, session, changed, ...what up answered }`.
39
40
  */
40
41
  export async function unpauseSessions(bots, { bot, session }) {
41
- const home = fleetMember(bots, bot, 'unpause');
42
+ const home = fleetMember(bots, bot, 'unpause', session);
42
43
  sessionsOf(readBot(home, bot), session);
43
44
 
44
45
  const changed = markPaused(bots, bot, session, false);
@@ -47,12 +48,16 @@ export async function unpauseSessions(bots, { bot, session }) {
47
48
 
48
49
  /**
49
50
  * 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
+ * or its management session: that is where the user asks to bring anything
52
+ * back. Bot Father's other sessions are like any bot's (#230).
51
53
  */
52
- export function fleetMember(bots, bot, verb) {
53
- if (bot === BOT_FATHER) {
54
+ export function fleetMember(bots, bot, verb, session) {
55
+ if (bot === BOT_FATHER && session === undefined) {
54
56
  throw new Error(`${BOT_FATHER} runs the fleet, and the kit does not ${verb} it: it is where you ask for everything else.`);
55
57
  }
58
+ if (bot === BOT_FATHER && session === DAILY_SESSION) {
59
+ throw new Error(`${DAILY_SESSION} is ${BOT_FATHER}'s management session, and the kit does not ${verb} it: it is where you ask for everything else.`);
60
+ }
56
61
  botsNamed(bots, bot);
57
62
  return realpathSync(botDir(bots, bot));
58
63
  }
package/src/record.js CHANGED
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // Both harnesses run the kit's hook (src/hooks.js) and hand it the session id
4
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.
5
+ // ids (ADR 0012) without the kit guessing from transcripts.
6
6
  //
7
7
  // Which session it is comes from the tab: Orca puts the tab id in the
8
8
  // environment of everything running in it, and the book says which session owns
package/src/restart.js CHANGED
@@ -5,7 +5,7 @@
5
5
  // avoided, not banned, and one happens when the user asks for it).
6
6
  //
7
7
  // When they do ask, the session has to come back as itself. The book holds the
8
- // conversation each session is running (ADR 0002), so a restart is: close the
8
+ // conversation each session is running (ADR 0012), so a restart is: close the
9
9
  // tab the book names, then let `up` open a new one and resume that
10
10
  // conversation. Nothing else about it is new.
11
11
  //
@@ -31,6 +31,7 @@ import { setTimeout as pause } from 'node:timers/promises';
31
31
 
32
32
  import { bookFile, readBook } from './book.js';
33
33
  import { botDir, readBot } from './bot.js';
34
+ import { ownCli, shellWord } from './launch.js';
34
35
  import { closeTab, findProject, tabs } from './orca.js';
35
36
  import { botsNamed, bringUp, prepareBots, sessionsOf } from './up.js';
36
37
 
@@ -49,10 +50,10 @@ export async function restartSessions(bots, { bot: name, session: onlySession }
49
50
  // A paused bot or session has no tab to close and is not to be opened, so
50
51
  // there is nothing here to restart.
51
52
  if (known.paused === true) {
52
- throw new Error(`${name} is paused, so there is nothing of it to restart and nothing was closed. Bring it back with obk unpause --bots ${bots} --bot ${name}.`);
53
+ throw new Error(`${name} is paused, so there is nothing of it to restart and nothing was closed. Bring it back with ${shellWord(ownCli())} unpause --bots ${shellWord(bots)} --bot ${name}.`);
53
54
  }
54
55
  if (onlySession !== undefined && sessions[0].paused === true) {
55
- throw new Error(`${name} ${onlySession} is paused, so there is nothing of it to restart and nothing was closed. Bring it back with obk unpause --bots ${bots} --bot ${name} --session ${onlySession}.`);
56
+ throw new Error(`${name} ${onlySession} is paused, so there is nothing of it to restart and nothing was closed. Bring it back with ${shellWord(ownCli())} unpause --bots ${shellWord(bots)} --bot ${name} --session ${onlySession}.`);
56
57
  }
57
58
 
58
59
  // And the same preparations, made now rather than left to `up`, which makes
@@ -158,7 +159,7 @@ async function gone(home, closed, bots, bot) {
158
159
 
159
160
  if (Date.now() >= until) {
160
161
  const one = left.length === 1;
161
- throw new Error(`Orca answered the close for ${one ? 'this tab' : 'these tabs'} and is still listing ${one ? 'it' : 'them'} ${SETTLED_MS / 1000} seconds later: ${left.join(', ')}. Nothing was opened in ${one ? 'its' : 'their'} place, because a tab that is on the way out is not a tab to start a harness in. The conversations are in the book: obk up --bots ${bots} --bot ${bot} brings them back once Orca has caught up.`);
162
+ throw new Error(`Orca answered the close for ${one ? 'this tab' : 'these tabs'} and is still listing ${one ? 'it' : 'them'} ${SETTLED_MS / 1000} seconds later: ${left.join(', ')}. Nothing was opened in ${one ? 'its' : 'their'} place, because a tab that is on the way out is not a tab to start a harness in. The conversations are in the book: ${shellWord(ownCli())} up --bots ${shellWord(bots)} --bot ${bot} brings them back once Orca has caught up.`);
162
163
  }
163
164
  await pause(ASK_MS);
164
165
  }
@@ -188,5 +189,5 @@ function alsoClosed(why, closed, bots, bot) {
188
189
  if (closed.length === 0) return why;
189
190
 
190
191
  const one = closed.length === 1;
191
- return `${why} ${closed.map((tab) => tab.name).join(', ')} ${one ? 'was' : 'were'} closed before it and nothing was opened in ${one ? 'its' : 'their'} place: obk up --bots ${bots} --bot ${bot} brings ${one ? 'it' : 'them'} back with ${one ? 'its' : 'their'} conversation.`;
192
+ return `${why} ${closed.map((tab) => tab.name).join(', ')} ${one ? 'was' : 'were'} closed before it and nothing was opened in ${one ? 'its' : 'their'} place: ${shellWord(ownCli())} up --bots ${shellWord(bots)} --bot ${bot} brings ${one ? 'it' : 'them'} back with ${one ? 'its' : 'their'} conversation.`;
192
193
  }
package/src/retire.js CHANGED
@@ -14,7 +14,7 @@
14
14
  //
15
15
  // Closing a tab here ends the conversation in it on purpose, so unlike a
16
16
  // restart or a pause it does not wait for the book to know which one it was.
17
- // The mailbox Runs stay: Orca has no way to remove one (ADR 0008).
17
+ // The mailbox Runs stay: Orca has no way to remove one (ADR 0018).
18
18
 
19
19
  import { existsSync, mkdirSync, renameSync, rmSync } from 'node:fs';
20
20
  import path from 'node:path';
@@ -34,7 +34,7 @@ export const retiredDir = (bots) => path.join(bots, 'retired');
34
34
  * Retire the session `session` of the bot `bot`. Returns `{ bot, session, closed }`.
35
35
  */
36
36
  export async function retireSession(bots, { bot, session }) {
37
- const home = fleetMember(bots, bot, 'retire');
37
+ const home = fleetMember(bots, bot, 'retire', session);
38
38
  const sessions = sessionsOf(readBot(home, bot), session);
39
39
 
40
40
  const closed = await closeTabs(home, tabsToClose(bots, bot, home, sessions, { keepless: true }), bots, bot);