@assuredloop/orca-bot-kit 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/health.js CHANGED
@@ -21,13 +21,14 @@
21
21
  import { existsSync, readdirSync, realpathSync } from 'node:fs';
22
22
  import path from 'node:path';
23
23
 
24
- import { bookFile, readBook, tabIdsIn } from './book.js';
24
+ import { bookFile, readBook, sessionIdsIn, tabIdsIn } from './book.js';
25
25
  import { botDir, botNames, botsDir, readBot } from './bot.js';
26
26
  import { transcriptsIn } from './conversations.js';
27
27
  import { hookTrouble } from './hooks.js';
28
- import { bypassFlags, harnessOf, HARNESSES, sessionTrouble } from './launch.js';
29
- import { orcaDefaultArgs, projects, tabs } from './orca.js';
30
- import { agentsTrouble } from './rules.js';
28
+ import { bypassFlags, harnessOf, HARNESSES, ownCli, sessionTrouble, shellWord } from './launch.js';
29
+ import { frontOfTab, orcaDefaultArgs, projects, tabs } from './orca.js';
30
+ import { agentsTrouble, rulesStamp } from './rules.js';
31
+ import { settingsInUse } from './settings.js';
31
32
  import { skillsTrouble } from './skills.js';
32
33
  import { readSources, sourcesDir } from './sources.js';
33
34
  import { BOT_FATHER, botsNamed, promptPath } from './up.js';
@@ -37,14 +38,18 @@ import { BOT_FATHER, botsNamed, promptPath } from './up.js';
37
38
  * settings do to every session, what is lying about that no book owns, and then
38
39
  * each bot in name order. `bot` narrows the per-bot checks to one; what the
39
40
  * whole fleet lives under is reported whichever bot was named.
41
+ *
42
+ * Beside the list, `sessions`: each running session the book knows, with what
43
+ * it runs on against what its bot asks for now, good news included.
40
44
  */
41
45
  export function checkHealth(bots, { bot: onlyBot } = {}) {
42
46
  const names = botsNamed(bots, onlyBot);
43
47
  const setups = projects();
44
48
 
45
49
  const found = [...orcaSettingFindings(), ...leftInOrca(bots, setups), ...leftBeside(bots)];
46
- for (const name of names) found.push(...aboutBot(bots, name, setups));
47
- return found;
50
+ const sessions = [];
51
+ for (const name of names) found.push(...aboutBot(bots, name, setups, sessions));
52
+ return { found, sessions };
48
53
  }
49
54
 
50
55
  /** One finding, as the report and the `--json` answer carry it. */
@@ -165,8 +170,8 @@ function leftBeside(bots) {
165
170
  return found;
166
171
  }
167
172
 
168
- /** Everything wrong with one bot. */
169
- function aboutBot(bots, name, setups) {
173
+ /** Everything wrong with one bot. Its running sessions are added to `sessions`. */
174
+ function aboutBot(bots, name, setups, sessions) {
170
175
  const home = botDir(bots, name);
171
176
 
172
177
  let bot;
@@ -185,7 +190,7 @@ function aboutBot(bots, name, setups) {
185
190
  ...agentsTrouble(bots, home, bot).map(said('config')),
186
191
  ...hooksOf(bots, home, bot).map(said('config')),
187
192
  ...skillsTrouble(bots, home, bot).map(said('skill')),
188
- ...inOrca(home, bot, setups),
193
+ ...inOrca(bots, home, bot, setups, sessions),
189
194
  ];
190
195
  }
191
196
 
@@ -206,12 +211,97 @@ const hooksOf = (bots, home, bot) => [...new Set(bot.sessions.map((session) => h
206
211
  .map((harness) => hookTrouble(home, harness, { bots, bot: bot.name }))
207
212
  .filter((trouble) => trouble !== undefined);
208
213
 
214
+ /**
215
+ * Each session of this bot running in a tab Orca has, with what it runs on set
216
+ * beside what the bot asks for now, added to `sessions`; a finding for each
217
+ * one that runs on something else (#271, #272); and a finding for each one
218
+ * whose tab is open with its harness gone from it (#300).
219
+ *
220
+ * Two questions, each answered from where the answer is written. The settings
221
+ * from the harness's own record of the conversation the session is in now,
222
+ * which is what the harness really used. The rules from the stamp the kit noted
223
+ * in the book when the session last read them, against `AGENTS.md` now: nothing
224
+ * the harness writes says which instructions it read.
225
+ *
226
+ * Only a mismatch and older rules are findings. What cannot be read is said as
227
+ * unknown in `sessions`, and never taken for agreement.
228
+ */
229
+ function runningOn(bots, home, bot, book, handles, sessions) {
230
+ const real = realpathOf(home) ?? home;
231
+ const records = new Map();
232
+ const recordOf = (harness, id) => {
233
+ if (!HARNESSES.includes(harness) || id === null) return undefined;
234
+ if (!records.has(harness)) records.set(harness, new Map(transcriptsIn(harness, real).map((one) => [one.id, one.file])));
235
+ return records.get(harness).get(id);
236
+ };
237
+
238
+ const stamp = rulesStamp(home);
239
+ const found = [];
240
+ for (const session of bot.sessions) {
241
+ const entry = book.sessions[session.name];
242
+ // Running is a tab Orca still has, with the session's harness in front of
243
+ // it. A session whose tab is gone is not running whatever its record says,
244
+ // and the missing tab is said already.
245
+ if (bot.paused === true || session.paused === true || entry === null || typeof entry !== 'object') continue;
246
+ const handle = handles.get(entry.tab);
247
+ if (handle === undefined) continue;
248
+
249
+ // A harness can quit to the tab's shell and leave the tab open (#232), so
250
+ // the tab alone is not the answer: who holds its terminal is, asked the way
251
+ // the kit asks before it types into a tab. The shell in front is a session
252
+ // that is not running, and a finding: `up` finds the tab open and types
253
+ // nothing into it, so only a restart brings it back (#300). Anything else
254
+ // that is not its own harness, or a front that cannot be read, leaves it
255
+ // unsaid, and nothing unsaid is a finding.
256
+ const restart = `${shellWord(ownCli())} restart --bots ${shellWord(bots)} --bot ${bot.name} --session ${session.name}`;
257
+ const front = frontOfTab(handle);
258
+ if (front.front === 'shell') {
259
+ // Restart refuses a live tab whose conversation the book cannot name, and
260
+ // there may be none to name: the harness can quit before it has one. Its
261
+ // harness is gone, so closing the tab ends nothing, and `up` then starts
262
+ // the session again as it does any closed tab with no conversation.
263
+ const up = `${shellWord(ownCli())} up --bots ${shellWord(bots)} --bot ${bot.name} --session ${session.name}`;
264
+ const back = typeof entry.session === 'string'
265
+ ? `${restart} does`
266
+ : `nor does obk restart, which refuses to close a tab whose conversation the book does not name, and the book names none for it. Its harness has already quit, so close the tab in Orca yourself; then ${up} starts it again, with a new conversation and its duty. Any conversation it left in this bot's folder is noted in the book as unclaimed, for health to list`;
267
+ found.push(finding('session', entry.tab, `${bot.name}'s session ${session.name} is not running: its tab ${entry.tab} is open with only the tab's shell in front, so its harness quit or crashed. obk up finds the tab open and types nothing into it, so it does not bring the session back; ${back}.`, bot.name));
268
+ continue;
269
+ }
270
+ const harness = harnessOf(session, bot.harness);
271
+ const running = front.front === 'program' && front.command === harness ? 'yes' : 'unknown';
272
+ const conversation = typeof entry.session === 'string' ? entry.session : null;
273
+ const file = recordOf(harness, conversation);
274
+ const settings = settingsInUse(harness, session, file, typeof entry.launched === 'string' ? entry.launched : undefined);
275
+ const rules = { state: typeof entry.rules !== 'string' ? 'unknown' : entry.rules === stamp ? 'current' : 'older' };
276
+ sessions.push({ bot: bot.name, session: session.name, harness, running, conversation, settings, rules });
277
+ if (running !== 'yes') continue;
278
+
279
+ const off = Object.entries(settings).filter(([, one]) => one.state === 'mismatch');
280
+ if (off.length > 0) {
281
+ const parts = off.map(([name, one]) => `${name}: bot.yaml asks for ${one.configured}, and it runs on ${one.observed}`).join('; ');
282
+ found.push(finding('session', file, `${bot.name}'s session ${session.name} does not run on what ${path.join(home, 'bot.yaml')} asks for. ${parts}. That is what the harness's own record of its conversation ${conversation} says. A session takes these when it starts, so if bot.yaml changed after it started, ${restart} starts it on them; if not, something else set them, such as a default of the harness's own or a change made inside the session.`, bot.name));
283
+ }
284
+
285
+ if (rules.state === 'older') {
286
+ const agents = path.join(home, 'AGENTS.md');
287
+ // A clear on Claude Code reads the instructions again, as a start does
288
+ // (tech notes, section 2); whether a new conversation on Codex does is
289
+ // not established, so a start is all that is offered there.
290
+ const how = harness === 'claude'
291
+ ? `A Claude Code session reads it again at a /clear in its tab, or when it starts: ${restart}`
292
+ : `A Codex session reads it when it starts: ${restart}`;
293
+ found.push(finding('session', agents, `${bot.name}'s session ${session.name} is running on older rules: ${agents} has changed since the kit noted which version this session read. ${how}`, bot.name));
294
+ }
295
+ }
296
+ return found;
297
+ }
298
+
209
299
  /**
210
300
  * What Orca has of this bot against what the book says it should have: a
211
301
  * session whose tab is gone, a conversation nobody claims, and a tab in the
212
302
  * bot's project that the book does not name.
213
303
  */
214
- function inOrca(home, bot, setups) {
304
+ function inOrca(bots, home, bot, setups, sessions) {
215
305
  // The book is read where it is read, and a book nothing can parse is a
216
306
  // finding like any other: it says which tab each session is in and which
217
307
  // conversation it is running, so without it nothing else here can be asked —
@@ -236,19 +326,26 @@ function inOrca(home, bot, setups) {
236
326
  // what paused means rather than something lost.
237
327
  const closed = new Set(bot.sessions.filter((session) => bot.paused === true || session.paused === true).map((session) => session.name));
238
328
 
329
+ // What the harness has on record in this bot's folder, read once: it is a walk
330
+ // through every conversation on the machine.
331
+ const onRecord = recordIn(real ?? home, bot);
332
+ // A conversation the harness marks as a subagent's was never a session's, so a
333
+ // note that holds one is not a loose end for it (#287). The note stays as it is.
334
+ const helpers = new Set(onRecord.filter((one) => one.subagent).map((one) => one.id));
335
+
239
336
  const found = [];
240
337
  for (const [name, entry] of Object.entries(book.sessions)) {
241
338
  if (typeof entry?.tab === 'string' && !there.has(entry.tab) && !closed.has(name)) {
242
339
  found.push(finding('session', entry.tab, `${bot.name}'s session ${name} is in the book with tab ${entry.tab}, and Orca has no tab of that id: the tab was closed, or the machine was restarted. The conversation is in the book, and obk up opens a tab and brings it back.`, bot.name));
243
340
  }
244
341
 
245
- const unclaimed = Array.isArray(entry?.unclaimed) ? entry.unclaimed : [];
342
+ const unclaimed = (Array.isArray(entry?.unclaimed) ? entry.unclaimed : []).filter((id) => !helpers.has(id));
246
343
  if (unclaimed.length > 0) {
247
344
  found.push(finding('leftover', bookFile(home), `The book notes conversations under ${bot.name}'s session ${name} that no session of this bot claims: ${unclaimed.join(', ')}. They ran in this bot's folder, and the kit will not say whose they were. To give one back, write it into ${bookFile(home)} under ${name} as session: <id> and run obk up again.`, bot.name));
248
345
  }
249
346
  }
250
347
 
251
- found.push(...offTheBook(real ?? home, bot, book));
348
+ found.push(...offTheBook(real ?? home, bot, book, onRecord));
252
349
 
253
350
  // A tab outside the book is somebody else's, and in Bot Father's project it
254
351
  // is the ops tab: the one tab the kit deliberately keeps no record of, so it
@@ -260,6 +357,7 @@ function inOrca(home, bot, setups) {
260
357
  }
261
358
  }
262
359
 
360
+ found.push(...runningOn(bots, home, bot, book, new Map(live.map((tab) => [tab.tabId, tab.handle])), sessions));
263
361
  return found;
264
362
  }
265
363
 
@@ -269,27 +367,30 @@ function inOrca(home, bot, setups) {
269
367
  * fails it fails quietly (ADR 0020), so this is where a stale book shows: the
270
368
  * harness's own record set beside it (ADR 0012).
271
369
  */
272
- function offTheBook(home, bot, book) {
273
- const named = new Set();
370
+ function offTheBook(home, bot, book, onRecord) {
371
+ // A retired session's conversations are in the book too, under `retired`.
372
+ const named = sessionIdsIn(book);
274
373
  for (const entry of Object.values(book.sessions)) {
275
- if (typeof entry?.session === 'string') named.add(entry.session);
276
- for (const was of Array.isArray(entry?.history) ? entry.history : []) named.add(was?.session);
277
374
  for (const id of Array.isArray(entry?.unclaimed) ? entry.unclaimed : []) named.add(id);
278
375
  }
279
376
 
280
- // Every harness any of its sessions runs on, as `obk usage` reads them.
281
- const harnesses = new Set(bot.sessions.map((session) => harnessOf(session, bot.harness)));
282
- if (harnesses.size === 0) harnesses.add(bot.harness);
283
- const stray = [...harnesses]
284
- .filter((harness) => HARNESSES.includes(harness))
285
- .flatMap((harness) => transcriptsIn(harness, home))
286
- .filter((one) => !named.has(one.id));
377
+ // A subagent's conversation is no session's, so the book has no reason to name it.
378
+ const stray = onRecord.filter((one) => !one.subagent && !named.has(one.id));
287
379
  if (stray.length === 0) return [];
288
380
 
289
381
  const listed = stray.map((one) => `${one.id} (begun ${one.at})`).join(', ');
290
382
  return [finding('session', bookFile(home), `The harness has conversations on record in ${bot.name}'s folder that the book does not name: ${listed}. The kit's hook may have missed them: a clear it did not record, or a Codex hooks file trusted late. To give one to a session, write it into ${bookFile(home)} under that session as session: <id> and run obk up again.`, bot.name)];
291
383
  }
292
384
 
385
+ /** Every conversation on record in the bot's folder, from every harness any of its sessions runs on, as `obk usage` reads them. */
386
+ function recordIn(home, bot) {
387
+ const harnesses = new Set(bot.sessions.map((session) => harnessOf(session, bot.harness)));
388
+ if (harnesses.size === 0) harnesses.add(bot.harness);
389
+ return [...harnesses]
390
+ .filter((harness) => HARNESSES.includes(harness))
391
+ .flatMap((harness) => transcriptsIn(harness, home));
392
+ }
393
+
293
394
  /** What a directory holds, and nothing at all when there is no directory. */
294
395
  function namesIn(dir) {
295
396
  try {
package/src/message.js CHANGED
@@ -21,7 +21,7 @@ import path from 'node:path';
21
21
  import { readBook } from './book.js';
22
22
  import { botDir, botNames, readBot } from './bot.js';
23
23
  import { harnessOf, ownCli, reachesMail, shellWord } from './launch.js';
24
- import { ackMailbox, harnessInTab, postMessage, readMailbox, tabs, typeIntoTab, useMailbox } from './orca.js';
24
+ import { ackMailbox, coordinatorOf, postMessage, readMailbox, tabs, tabToTypeInto, 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
@@ -173,7 +173,9 @@ export function sendMessage(bots, { to: target, from: sender, tab, subject, text
173
173
  /**
174
174
  * What is waiting for a session, and the reading of it.
175
175
  *
176
- * Orca fences a Run to one reader, so this binds before it reads. A read that
176
+ * Orca fences a Run to one reader, its coordinator. A session whose tab is live
177
+ * is bound to that tab and read as it; one whose tab is down is read as its
178
+ * Run's coordinator, and nothing is bound (issue #249). A read that
177
179
  * is not a peek acknowledges the batch it read: that is what makes the next
178
180
  * check bring the next one rather than the same again.
179
181
  */
@@ -195,9 +197,21 @@ export function checkMail(bots, { bot: botName, session: sessionName, tab, peek
195
197
  // Read as the session's own tab, wherever this check was typed. Binding the
196
198
  // tab that asked would hand it the session's mailbox, and Orca's notice for
197
199
  // 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);
200
+ // whatever its mailbox is bound to, a closed tab included, and nothing is
201
+ // bound: the session's tab is bound again when it is back up (issue #249).
202
+ const live = who.tab === undefined ? undefined : tabs(who.home).find((tab) => tab.tabId === who.tab)?.handle;
203
+ if (live !== undefined) useMailbox(who.mailbox, live);
204
+ const handle = live ?? coordinatorOf(who.mailbox);
205
+ if (handle === undefined) {
206
+ return {
207
+ bots,
208
+ bot: who.bot,
209
+ session: who.session,
210
+ mailbox: who.mailbox,
211
+ messages: [],
212
+ trouble: `${who.bot}/${who.session} is not up, and its mailbox is bound to no tab, so there is nothing to read it as. Its mail waits until its tab is back: \`up\` brings it back, or \`unpause\` when it is paused.`,
213
+ };
214
+ }
201
215
  const found = readMailbox(who.mailbox, { peek, handle });
202
216
  const messages = (found.messages ?? []).map((message) => asMessage(bots, message));
203
217
  if (!peek && found.deliveryId !== undefined && messages.length > 0) ackMailbox(who.mailbox, found.deliveryId, handle);
@@ -321,31 +335,14 @@ function nudge(to, from, subject) {
321
335
  if (to.tab === undefined) return { nudged: false };
322
336
 
323
337
  try {
324
- const live = tabs(to.home).find((tab) => tab.tabId === to.tab);
325
- if (live === undefined) return { nudged: false };
326
-
327
- const seen = harnessInTab(live.handle, LOOK_MS);
328
- if (seen.blockedReason !== undefined) return { nudged: false, blocked: seen.blockedReason };
329
- // The shell in front is a tab with no harness, whatever a stale
330
- // `agentIdentity` says.
331
- if (seen.front === 'shell') return { nudged: false };
332
- // Not knowing is not a reason to type: a line that lands in a shell is run
333
- // there, with the sender's subject in it. The program in front has to be
334
- // the agent Orca names. With no name it may be a harness a few seconds
335
- // into its launch; under another name it may be a pager started after the
336
- // harness quit, under an identity Orca has not let go of (tech notes,
337
- // section 1). A harness run through a wrapper such as `node` lands here
338
- // too, until #261.
339
- if (seen.front === undefined || seen.agent === undefined || seen.command !== seen.agent) {
340
- const why = seen.front === undefined
341
- ? seen.unreadable
342
- : `${seen.command} holds its terminal, and Orca names ${seen.agent ?? 'no agent'} in it`;
343
- return { nudged: false, nudgeTrouble: `the kit could not tell whether a harness is running in it (${why}), so nothing was typed` };
344
- }
345
- // A busy harness is typed into: it takes the line as its next turn.
338
+ const found = tabToTypeInto(to.home, to.tab, LOOK_MS);
339
+ if (found.blocked !== undefined) return { nudged: false, blocked: found.blocked };
340
+ // A line that lands in a shell is run there, with the sender's subject in it.
341
+ if (found.unsure !== undefined) return { nudged: false, nudgeTrouble: found.unsure };
342
+ if (found.handle === undefined) return { nudged: false };
346
343
 
347
344
  typeIntoTab(
348
- live.handle,
345
+ found.handle,
349
346
  `Fleet mail from ${from.bot}/${from.session}: ${subject}. Read it with ${shellWord(ownCli())} message check --bots ${shellWord(to.bots)} --bot ${shellWord(to.bot)} --session ${shellWord(to.session)}`,
350
347
  );
351
348
  return { nudged: true };
@@ -0,0 +1,21 @@
1
+ // Run by Orca's own binary as plain Node, the way Orca's `bin/orca` runs its
2
+ // CLI: `ELECTRON_RUN_AS_NODE=1 <Orca> orca-window.cjs <client> <projectId> <wait>`.
3
+ //
4
+ // It asks Orca's runtime, through Orca's own client, for `project.update` with
5
+ // no changes on one project. That is the call that makes Orca's window read its
6
+ // projects again, and Orca's CLI does not offer it (#224, ADR 0021). It exits 0
7
+ // only when the runtime took the call; anything else is a non-zero exit, and
8
+ // the kit reads every non-zero exit the same way.
9
+
10
+ 'use strict';
11
+
12
+ const [client, projectId, wait] = process.argv.slice(2);
13
+
14
+ process.exitCode = 1;
15
+ const { RuntimeClient } = require(client);
16
+ new RuntimeClient(undefined, Number(wait))
17
+ .call('project.update', { projectId, updates: {} })
18
+ .then((answer) => {
19
+ if (answer?.ok === true) process.exitCode = 0;
20
+ }, () => {})
21
+ .finally(() => process.exit());
package/src/orca.js CHANGED
@@ -1,4 +1,4 @@
1
- // Every call the kit makes to Orca goes through here (ADR 0011). Orca's CLI
1
+ // Every call the kit makes to Orca goes through here (ADR 0021). 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
  //
@@ -8,9 +8,10 @@
8
8
  // handed to Orca on the command line.
9
9
 
10
10
  import { spawnSync } from 'node:child_process';
11
- import { existsSync, readdirSync, readFileSync } from 'node:fs';
11
+ import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs';
12
12
  import { homedir } from 'node:os';
13
13
  import path from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
14
15
 
15
16
  // The Orca that works for a normal user: `/usr/local/bin/orca` is a root-only
16
17
  // symlink on this machine (tech notes, section 1). OBK_ORCA overrides it, for a
@@ -162,6 +163,49 @@ export function asFolderProject(setupId, title) {
162
163
  */
163
164
  export const deleteProject = (setupId) => orca(['project', 'setup-delete', '--setup', setupId]);
164
165
 
166
+ /** Said after a run that made, renamed or removed a project, whatever `tellWindow` answered. */
167
+ export const RELOAD_LINE = "If Orca's sidebar does not show it, reload the window with Cmd+Shift+R.";
168
+
169
+ /** How long Orca's client waits for its runtime, and how long the kit waits for the client. */
170
+ const CLIENT_WAIT_MS = 2000;
171
+ const CLIENT_KILL_MS = 3000;
172
+
173
+ /**
174
+ * Tell Orca's window that the project `projectId` changed, so it reads its
175
+ * projects again: true when Orca's runtime took the call, false otherwise.
176
+ *
177
+ * Orca's window re-reads only when its runtime says the projects changed, and
178
+ * `setup-update` and `setup-delete` do not say so. `project.update` with no
179
+ * changes does, and Orca's CLI does not offer it, so this goes through Orca's
180
+ * own runtime client out of the installed app, run by Orca's binary the way
181
+ * its `bin/orca` runs its CLI (ADR 0021). None of that is Orca's published
182
+ * interface, so anything that goes wrong is a quiet false: it never throws and
183
+ * is never tried twice, and the caller prints `RELOAD_LINE` either way.
184
+ */
185
+ export function tellWindow(projectId) {
186
+ try {
187
+ // The CLI sits at <Orca.app>/Contents/Resources/bin/orca, often reached
188
+ // through a link.
189
+ const contents = path.resolve(realpathSync(orcaCli()), '..', '..', '..');
190
+ const client = path.join(contents, 'Resources', 'app.asar.unpacked', 'out', 'cli', 'runtime-client.js');
191
+ const env = { ...process.env, ELECTRON_RUN_AS_NODE: '1' };
192
+ // As Orca's `bin/orca` does: the kit's own Node options are not Orca's.
193
+ delete env.NODE_OPTIONS;
194
+ delete env.NODE_REPL_EXTERNAL_MODULE;
195
+
196
+ const asked = spawnSync(
197
+ path.join(contents, 'MacOS', 'Orca'),
198
+ [WINDOW_SCRIPT, client, projectId, String(CLIENT_WAIT_MS)],
199
+ { env, stdio: 'ignore', timeout: CLIENT_KILL_MS },
200
+ );
201
+ return asked.error === undefined && asked.status === 0;
202
+ } catch {
203
+ return false;
204
+ }
205
+ }
206
+
207
+ const WINDOW_SCRIPT = fileURLToPath(new URL('./orca-window.cjs', import.meta.url));
208
+
165
209
  /**
166
210
  * The live tabs of the Orca project at `home`, each under its own tab id.
167
211
  *
@@ -226,6 +270,63 @@ export function harnessInTab(handle, timeoutMs) {
226
270
  };
227
271
  }
228
272
 
273
+ /**
274
+ * Whether the kit may type a line into `tabId`, the tab the book holds for a
275
+ * session in the Orca project at `home`, and the handle to type into when it
276
+ * may: `{ handle, agent }`, with the agent Orca names there. Otherwise nothing
277
+ * is typed, and the answer says why:
278
+ * `{}` for a tab with no harness in it (none in the book, none Orca lists, or
279
+ * the shell in front), `{ blocked }` for one with something on screen waiting
280
+ * to be answered, and `{ unsure }`, a sentence, for one the kit cannot tell
281
+ * about. Orca refusing throws, for the caller to report.
282
+ *
283
+ * The one gate for every line the kit types into a running session: the mail
284
+ * nudge and the skills reload. A busy harness passes it, since both harnesses
285
+ * take a typed line as their next turn.
286
+ */
287
+ export function tabToTypeInto(home, tabId, timeoutMs) {
288
+ if (tabId === undefined) return {};
289
+ const live = tabs(home).find((tab) => tab.tabId === tabId);
290
+ if (live === undefined) return {};
291
+
292
+ const seen = harnessInTab(live.handle, timeoutMs);
293
+ // A line typed into a screen waiting for an answer is that answer: once, it
294
+ // confirmed Claude Code's folder-trust default, `No, exit` (tech notes,
295
+ // section 1).
296
+ if (seen.blockedReason !== undefined) return { blocked: seen.blockedReason };
297
+ // The shell in front is a tab with no harness, whatever a stale
298
+ // `agentIdentity` says.
299
+ if (seen.front === 'shell') return {};
300
+ // Not knowing is not a reason to type: a line that lands in a shell is run
301
+ // there. The program in front has to be the agent Orca names. With no name
302
+ // it may be a harness a few seconds into its launch; under another name it
303
+ // may be a pager started after the harness quit, under an identity Orca has
304
+ // not let go of (tech notes, section 1). A harness run through a wrapper such
305
+ // as `node` lands here too, until #261.
306
+ if (seen.front === undefined || seen.agent === undefined || seen.command !== seen.agent) {
307
+ const why = seen.front === undefined
308
+ ? seen.unreadable
309
+ : `${seen.command} holds its terminal, and Orca names ${seen.agent ?? 'no agent'} in it`;
310
+ return { unsure: `the kit could not tell whether a harness is running in it (${why}), so nothing was typed` };
311
+ }
312
+ return { handle: live.handle, agent: seen.agent };
313
+ }
314
+
315
+ /**
316
+ * Who holds the terminal of the tab `handle`, as `frontOf` answers, without
317
+ * waiting on the tab: for a caller that asks about every tab and has nothing
318
+ * to type. Orca refusing is an answer that cannot be read, not an error.
319
+ */
320
+ export function frontOfTab(handle) {
321
+ let shown;
322
+ try {
323
+ shown = orca(['terminal', 'show', '--terminal', handle]).terminal;
324
+ } catch (error) {
325
+ return { unreadable: `Orca would not show the tab: ${error.message}` };
326
+ }
327
+ return frontOf(shown?.ptyId);
328
+ }
329
+
229
330
  /** The `ps` this run reads. OBK_PS overrides it, as OBK_ORCA does Orca. */
230
331
  const psCli = () => process.env.OBK_PS || '/bin/ps';
231
332
 
@@ -235,7 +336,7 @@ const psCli = () => process.env.OBK_PS || '/bin/ps';
235
336
  * group in front, or `{ unreadable: <why> }`.
236
337
  *
237
338
  * 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).
339
+ * gives that pid's terminal's foreground process group (ADR 0021).
239
340
  * On macOS the pane is `login` with the shell as its child, so the shell is in
240
341
  * front when the group is the pane's own or that of a child of a `login` pane.
241
342
  * `diagnostics memory` is a diagnostics command and may change, so everything
@@ -326,17 +427,23 @@ export const makeMailbox = (objective, handle) =>
326
427
  orca(['orchestration', 'run-create', '--objective', `obk ${objective}`, '--from', handle]).run.id;
327
428
 
328
429
  /**
329
- * Take a turn at reading a mailbox, as the terminal `handle`.
330
- *
331
- * Orca fences a Run to one reader: a `check` from a terminal bound elsewhere is
332
- * refused with `consumer_fenced`, whatever the Run says. So a read binds first,
333
- * every time — the kit's runs are short and the binding is the last one to have
334
- * asked, not a lease anybody has to give back. Without a handle it binds this
335
- * process's own terminal.
430
+ * Bind a mailbox to the terminal `handle`, which makes it the Run's coordinator
431
+ * and its one reader: Orca refuses a `check` from any other terminal with
432
+ * `consumer_fenced`. A read itself binds nothing, so this is for a session's own
433
+ * live tab, never for whoever is reading (tech notes, section 1). Without a
434
+ * handle it binds this process's own terminal.
336
435
  */
337
436
  export const useMailbox = (id, handle) =>
338
437
  orca(['orchestration', 'run-use', '--id', id, ...(handle === undefined ? [] : ['--from', handle])]).run;
339
438
 
439
+ /**
440
+ * The terminal a mailbox is bound to, or undefined when it has none. A closed
441
+ * tab keeps its binding, and a read as its handle still works: a read binds
442
+ * nothing, only `run-use` does (tech notes, section 1).
443
+ */
444
+ export const coordinatorOf = (id) =>
445
+ orca(['orchestration', 'run-show', '--id', id]).run.coordinator_handle ?? undefined;
446
+
340
447
  /** Queue one message. `to` and `from` are mailboxes, written `run:<id>`. */
341
448
  export function postMessage({ to, from, subject, body, type = 'status', thread }) {
342
449
  const args = ['orchestration', 'send', '--to', to, '--subject', subject, '--body', body, '--type', type];
package/src/record.js CHANGED
@@ -17,6 +17,7 @@ import { forgetClaimed, readBook, rememberSession, sessionIdsIn, updateBook, wit
17
17
  import { botDir, readBot } from './bot.js';
18
18
  import { conversationsIn } from './conversations.js';
19
19
  import { harnessOf, SHELL_ENV, startPrompt, workDirOf } from './launch.js';
20
+ import { rulesStamp } from './rules.js';
20
21
 
21
22
  /** Where Orca says which tab a program is running in (tech notes, section 1). */
22
23
  export const TAB_ENV = 'ORCA_TAB_ID';
@@ -79,6 +80,11 @@ export async function recordSession(bots, name, said, tabId, shellPid) {
79
80
  // this bot's folder, which every session of the bot shares — so it can say
80
81
  // that a conversation nobody claims exists, and never whose it is.
81
82
  const unclaimed = unclaimedFor(readBook(home), home, bot, tabId, id);
83
+ // A clear on Claude Code reads the bot's instructions again, as a start does
84
+ // (tech notes, section 2), so the book notes which ones it read. Codex has no
85
+ // word for its clear, and whether its new conversation reads them again is
86
+ // not established, so nothing is noted for it.
87
+ const rules = said.source === CLEARED ? rulesStamp(home) : undefined;
82
88
 
83
89
  let cleared = false;
84
90
  let told;
@@ -100,7 +106,8 @@ export async function recordSession(bots, name, said, tabId, shellPid) {
100
106
  const uncertain = was.session === undefined && unclaimed.length > 0;
101
107
  told = session;
102
108
 
103
- book.sessions[session] = withUnclaimed(rememberSession(was, id, said.source), unclaimed);
109
+ const now = rememberSession(was, id, said.source);
110
+ book.sessions[session] = withUnclaimed(rules === undefined ? now : { ...now, rules }, unclaimed);
104
111
  forgetClaimed(book);
105
112
  if (uncertain) cleared = true;
106
113
  return book;
package/src/retire.js CHANGED
@@ -16,16 +16,16 @@
16
16
  // restart or a pause it does not wait for the book to know which one it was.
17
17
  // The mailbox Runs stay: Orca has no way to remove one (ADR 0018).
18
18
 
19
- import { existsSync, mkdirSync, renameSync, rmSync } from 'node:fs';
19
+ import { existsSync, mkdirSync, realpathSync, renameSync, rmSync } from 'node:fs';
20
20
  import path from 'node:path';
21
21
 
22
22
  import { readBook, tabIdsIn, updateBook } from './book.js';
23
23
  import { botDir, dropSession, readBot } from './bot.js';
24
- import { deleteProject, findProject, tabs } from './orca.js';
24
+ import { deleteProject, findProject, projects, tabs, tellWindow } from './orca.js';
25
25
  import { fleetMember } from './pause.js';
26
26
  import { closeTabs, tabsToClose } from './restart.js';
27
27
  import { unlinkSkills } from './skills.js';
28
- import { promptPath, sessionsOf } from './up.js';
28
+ import { BOT_FATHER, promptPath, sessionsOf } from './up.js';
29
29
 
30
30
  /** Where retired bots go: beside `bots/`, where nothing the kit runs looks. */
31
31
  export const retiredDir = (bots) => path.join(bots, 'retired');
@@ -55,6 +55,8 @@ export async function retireSession(bots, { bot, session }) {
55
55
  /**
56
56
  * Retire the bot `bot`. Returns `{ bot, closed, project, moved }`: the tabs it
57
57
  * closed, the Orca project it took away (if it had one) and where the bot is now.
58
+ * When Orca does not confirm the project gone, it returns `{ bot, closed,
59
+ * project, trouble }` instead, and the bot is left where it was.
58
60
  */
59
61
  export async function retireBot(bots, { bot }) {
60
62
  const home = fleetMember(bots, bot, 'retire');
@@ -79,7 +81,24 @@ export async function retireBot(bots, { bot }) {
79
81
  // they are gone.
80
82
  const booked = Object.keys(readBook(home).sessions).map((name) => ({ name }));
81
83
  const closed = await closeTabs(home, tabsToClose(bots, bot, home, booked, { keepless: true }), bots, bot);
82
- if (project !== undefined) deleteProject(project.id);
84
+ if (project !== undefined) {
85
+ deleteProject(project.id);
86
+ // Orca's answer to the delete is not the same as the project being gone
87
+ // (#282): its list afterwards is. Until that list is read and no longer
88
+ // has the project, the bot stays where it is, so that retiring it again
89
+ // takes the project away and then finishes. The list is read once, for
90
+ // this and for the window both.
91
+ let setups;
92
+ try {
93
+ setups = projects();
94
+ } catch (error) {
95
+ return { bot, closed, project: project.id, trouble: `Orca answered the delete of Orca project ${project.id}, and its project list could not be read afterwards, so the removal is not confirmed: ${error.message}. ${bot} was not moved. Once Orca is answering, retire ${bot} again with obk retire: it removes the project if it is still there, then finishes.` };
96
+ }
97
+ if (setups.some((setup) => setup.id === project.id || setup.path === home)) {
98
+ return { bot, closed, project: project.id, trouble: `Orca answered the delete of Orca project ${project.id}, and still lists it. ${bot} was not moved. Retire ${bot} again with obk retire; if Orca still lists the project after that, remove it in Orca yourself, then retire ${bot} again.` };
99
+ }
100
+ tellWindowOfRemoval(bots, setups);
101
+ }
83
102
 
84
103
  for (const name of new Set([...known.sessions, ...booked].map((session) => session.name))) {
85
104
  rmSync(promptPath(bots, bot, name), { force: true });
@@ -90,3 +109,19 @@ export async function retireBot(bots, { bot }) {
90
109
 
91
110
  return { bot, closed, ...(project === undefined ? {} : { project: project.id }), moved };
92
111
  }
112
+
113
+ /**
114
+ * Tell Orca's window a project went (#224). The call names a project that is
115
+ * still there, and Bot Father's is the one that always is; when Orca has none
116
+ * for it in `setups`, Orca's list after the delete, there is nothing to call
117
+ * on. Finding it is part of the workaround, so it fails as quietly as the call.
118
+ */
119
+ function tellWindowOfRemoval(bots, setups) {
120
+ try {
121
+ const home = botDir(bots, BOT_FATHER);
122
+ const father = existsSync(home) ? setups.find((setup) => setup.path === realpathSync(home)) : undefined;
123
+ if (father !== undefined) tellWindow(father.projectId);
124
+ } catch {
125
+ // Nothing: see above.
126
+ }
127
+ }
package/src/rules.js CHANGED
@@ -406,3 +406,18 @@ function sameFile(one, other) {
406
406
  return false;
407
407
  }
408
408
  }
409
+
410
+ /**
411
+ * A stamp of a bot's `AGENTS.md` as it is now, or undefined when there is none
412
+ * to read. It is what a session read when it started, noted in the book at that
413
+ * moment, so that health can tell a session running on older rules from one on
414
+ * the current ones. The whole file, not only the build's block: what the user
415
+ * wrote below the end marker is part of what the session reads.
416
+ */
417
+ export function rulesStamp(home) {
418
+ try {
419
+ return checksum(readFileSync(path.join(home, AGENTS), 'utf8'));
420
+ } catch {
421
+ return undefined;
422
+ }
423
+ }