@assuredloop/orca-bot-kit 0.20.0 → 0.22.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/message.js CHANGED
@@ -15,7 +15,8 @@
15
15
  // one line into the receiver's tab. Both harnesses queue a typed line while
16
16
  // they are busy, which is what PRD 6.9 means by queued and not interrupting.
17
17
 
18
- import { mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
18
+ import { mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
19
+ import os from 'node:os';
19
20
  import path from 'node:path';
20
21
 
21
22
  import { MAILBOX_WAIT_MS, readBook, takeMailboxTurn } from './book.js';
@@ -172,7 +173,7 @@ export function sendMessage(bots, { to: target, from: sender, tab, subject, text
172
173
  id: message.id,
173
174
  thread: message.thread_id ?? thread,
174
175
  file: written.file,
175
- ...nudge(to, from, subject),
176
+ ...nudge(to, from, subject, tab),
176
177
  };
177
178
  }
178
179
 
@@ -452,14 +453,14 @@ const stamp = () => new Date().toISOString().replaceAll(':', '-').replace('.', '
452
453
  * it is nobody sending to a session before its first screens are answered,
453
454
  * which is the caller's work either way.
454
455
  */
455
- function nudge(to, from, subject) {
456
+ function nudge(to, from, subject, tab) {
456
457
  if (to.tab === undefined) return { nudged: false };
457
458
 
458
459
  try {
459
460
  const found = lookAt(to);
460
461
  if (found.blocked !== undefined) return { nudged: false, blocked: found.blocked };
461
462
  // A line that lands in a shell is run there, with the sender's subject in it.
462
- if (found.unsure !== undefined) return { nudged: false, nudgeTrouble: found.unsure };
463
+ if (found.unsure !== undefined) return { nudged: false, nudgeTrouble: found.unsure, ...(found.psUnread ? leftForHook(to, from, subject, tab) : {}) };
463
464
  if (found.handle === undefined) return { nudged: false };
464
465
 
465
466
  const sent = typeIntoTab(
@@ -485,6 +486,82 @@ function nudge(to, from, subject) {
485
486
  }
486
487
  }
487
488
 
489
+ /**
490
+ * The nudge a Codex sender could not decide, left for its own hook (#350, ADR
491
+ * 0034): `{ nudgeLeft: true }`, or nothing when it is not left.
492
+ *
493
+ * Inside Codex's sandbox `ps` does not start, and for a Claude receiver busy
494
+ * with a command Orca's runtime cannot see past it either. Codex runs its
495
+ * hooks outside the sandbox, and the kit's `PostToolUse` hook runs right after
496
+ * the command that made this send, in the same tab, where `ps` runs. So the
497
+ * send leaves the nudge where that hook looks, the system temp folder under the
498
+ * tab's id, which the sandbox lets it write and the hook reads. Only for a
499
+ * Codex session sending from its own tab, and only where `ps` could not read
500
+ * the receiver's tab: anywhere else no hook would see it, or would see no more
501
+ * than the send did.
502
+ */
503
+ function leftForHook(to, from, subject, tab) {
504
+ if (tab === undefined || from.tab !== tab || from.harness !== 'codex') return {};
505
+ try {
506
+ const dir = leftDir(tab);
507
+ mkdirSync(dir, { recursive: true });
508
+ const left = { bots: to.bots, to: `${to.bot}/${to.session}`, from: `${from.bot}/${from.session}`, subject };
509
+ // Written under a name the hook does not take, then put in place whole: a
510
+ // hook running beside this send never takes a file still being written.
511
+ const name = path.join(dir, `${stamp()}.${process.pid}`);
512
+ writeFileSync(`${name}.writing`, `${JSON.stringify(left)}\n`);
513
+ renameSync(`${name}.writing`, `${name}.json`);
514
+ return { nudgeLeft: true };
515
+ } catch {
516
+ return {};
517
+ }
518
+ }
519
+
520
+ /** Where the nudges left from one tab wait for its hook. */
521
+ const leftDir = (tab) => path.join(os.tmpdir(), 'obk-nudges', encodeURIComponent(tab));
522
+
523
+ /**
524
+ * What the kit's Codex hook does after each shell command (#350): decide every
525
+ * nudge a send in this tab left, oldest first, with the gate the send uses, and
526
+ * type it or not. Each is taken out before it is looked at, so it is decided
527
+ * once, by one hook. Returns `{ to, subject, ...what the nudge came to }` for
528
+ * each, as the send's own answer says it.
529
+ */
530
+ export function decideLeftNudges(tab) {
531
+ if (tab === undefined) return [];
532
+ const dir = leftDir(tab);
533
+ let names;
534
+ try {
535
+ names = readdirSync(dir).filter((name) => name.endsWith('.json')).sort();
536
+ } catch {
537
+ return [];
538
+ }
539
+ return names.flatMap((name) => {
540
+ const taking = path.join(dir, `${name}.taking`);
541
+ // Another hook took it first: it is that hook's to decide, and to remove.
542
+ try {
543
+ renameSync(path.join(dir, name), taking);
544
+ } catch {
545
+ return [];
546
+ }
547
+ let left;
548
+ try {
549
+ left = JSON.parse(readFileSync(taking, 'utf8'));
550
+ } catch {
551
+ return [];
552
+ } finally {
553
+ rmSync(taking, { force: true });
554
+ }
555
+ try {
556
+ const to = findSession(left.bots, left.to);
557
+ const from = findSession(left.bots, left.from);
558
+ return [{ to: left.to, subject: left.subject, ...nudge(to, from, left.subject) }];
559
+ } catch (error) {
560
+ return [{ to: left.to, subject: left.subject, nudged: false, nudgeTrouble: error.message }];
561
+ }
562
+ });
563
+ }
564
+
488
565
  /** Orca's words about a line it did not see start a turn, or what its receipt says when it gives none. */
489
566
  function unseen(sent) {
490
567
  const warnings = Array.isArray(sent?.warnings) ? sent.warnings.filter((warning) => typeof warning === 'string') : [];
@@ -1,5 +1,5 @@
1
1
  -- Run by the kit as `osascript orca-reload.applescript <Orca.app path>`, after
2
- -- a project was removed (#343, ADR 0031).
2
+ -- a project was removed (#343, ADR 0034).
3
3
  --
4
4
  -- Orca's window keeps a removed project in its sidebar until the window is
5
5
  -- rebuilt (stablyai/orca#23224; once a release fixes it, this can go behind a
@@ -2,7 +2,7 @@
2
2
  // CLI: `ELECTRON_RUN_AS_NODE=1 <Orca> orca-runtime.cjs <client> <method> <params> <wait>`.
3
3
  //
4
4
  // It makes one call to Orca's runtime, through Orca's own client, for a method
5
- // Orca's CLI does not offer (ADR 0031): `project.update`, which makes Orca's
5
+ // Orca's CLI does not offer (ADR 0034): `project.update`, which makes Orca's
6
6
  // window read its projects again (#224), and `terminal.inspectProcess`, which
7
7
  // says who is in front of a tab where `ps` cannot (#298). `params` is JSON. It
8
8
  // prints the runtime's answer as JSON and exits 0 only when the runtime took
package/src/orca.js CHANGED
@@ -1,4 +1,4 @@
1
- // Every call the kit makes to Orca goes through here (ADR 0031). Orca's CLI
1
+ // Every call the kit makes to Orca goes through here (ADR 0034). 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
  //
@@ -216,7 +216,7 @@ const CLIENT_KILL_MS = 3000;
216
216
  *
217
217
  * Orca's window re-reads only when its runtime says the projects changed, and
218
218
  * `setup-update` and `setup-delete` do not say so. `project.update` with no
219
- * changes does, and Orca's CLI does not offer it (ADR 0031). The caller prints
219
+ * changes does, and Orca's CLI does not offer it (ADR 0034). The caller prints
220
220
  * `RELOAD_LINE` either way. It does not take a removed project out of the
221
221
  * sidebar: `reloadWindow` does.
222
222
  */
@@ -227,7 +227,7 @@ export const tellWindow = (projectId) => askRuntime('project.update', { projectI
227
227
  * runtime's answer, or undefined when there is none to be had.
228
228
  *
229
229
  * It goes through Orca's own runtime client out of the installed app, run by
230
- * Orca's binary the way its `bin/orca` runs its CLI (ADR 0031). None of that is
230
+ * Orca's binary the way its `bin/orca` runs its CLI (ADR 0034). None of that is
231
231
  * Orca's published interface, so anything that goes wrong is a quiet
232
232
  * undefined: it never throws and is never tried twice.
233
233
  */
@@ -264,7 +264,7 @@ const RELOAD_KILL_MS = 5000;
264
264
 
265
265
  /**
266
266
  * Have Orca's window force-reload itself, after a project was removed: true
267
- * only when it did (#343, ADR 0031).
267
+ * only when it did (#343, ADR 0034).
268
268
  *
269
269
  * The window keeps a removed project in its sidebar until it is rebuilt, and
270
270
  * `tellWindow` only relabels the row "Unknown" (stablyai/orca#23224; once a
@@ -418,7 +418,7 @@ const ON_A_CHOICE = /^( *[›❯] +)\d+\. /;
418
418
  * Whether the rows of a rendered screen hold a question of the harness's own.
419
419
  *
420
420
  * Every one seen is a numbered list of choices with the harness's pointer on
421
- * one (tech notes, section 1; ADR 0031). The same pointer starts the harness's
421
+ * one (tech notes, section 1; ADR 0034). The same pointer starts the harness's
422
422
  * input line and its echo of the user's past turns, and the input line is the
423
423
  * lowest of them whenever it is on screen, so only the lowest pointer row is
424
424
  * asked about: a question counts while it stands in the input line's place.
@@ -449,7 +449,8 @@ export function questionIn(rows) {
449
449
  * `{}` for a tab with no harness in it (none in the book, none Orca lists, or
450
450
  * the shell in front), `{ blocked }` for one with something on screen waiting
451
451
  * to be answered, and `{ unsure }`, a sentence, for one the kit cannot tell
452
- * about. Orca refusing throws, for the caller to report.
452
+ * about, with `psUnread` where `ps` could not read the tab (#350). Orca
453
+ * refusing throws, for the caller to report.
453
454
  *
454
455
  * The one gate for every line the kit types into a running session: the mail
455
456
  * nudge and the skills reload. A busy harness passes it, since both harnesses
@@ -497,7 +498,8 @@ export function tabToTypeInto(home, tabId, timeoutMs) {
497
498
  const why = seen.front === undefined
498
499
  ? seen.unreadable
499
500
  : `${seen.command} holds its terminal, and Orca names ${seen.agent ?? 'no agent'} in it`;
500
- return { unsure: `the kit could not tell whether a harness is running in it (${why}), so nothing was typed` };
501
+ // Where `ps` could not read the tab, a reader that can may yet tell (#350).
502
+ return { unsure: `the kit could not tell whether a harness is running in it (${why}), so nothing was typed`, ...(seen.psUnread ? { psUnread: true } : {}) };
501
503
  }
502
504
  // Orca's reason does not cover every question: it called Codex's update
503
505
  // offer idle, and a return typed into it took "Update now" (#329). So the
@@ -554,13 +556,13 @@ const psCli = () => process.env.OBK_PS || '/bin/ps';
554
556
  *
555
557
  * `ps` is asked first. Where it cannot read the tab, as inside Codex's
556
558
  * sandbox, where it does not start at all, Orca's runtime is asked instead
557
- * (#298, ADR 0031).
559
+ * (#298, ADR 0034).
558
560
  */
559
561
  function frontOf(handle, ptyId, readMs) {
560
562
  const read = frontByPs(ptyId, readMs);
561
563
  if (read.unreadable === undefined) return read;
562
564
  const asked = frontByOrca(handle);
563
- return asked.unreadable === undefined ? asked : { unreadable: `${read.unreadable}, and ${asked.unreadable}` };
565
+ return { ...(asked.unreadable === undefined ? asked : { unreadable: `${read.unreadable}, and ${asked.unreadable}` }), psUnread: true };
564
566
  }
565
567
 
566
568
  /**
@@ -590,7 +592,7 @@ function frontByOrca(handle) {
590
592
  * Who holds the terminal of the pane `ptyId`, as `ps` says it.
591
593
  *
592
594
  * Orca gives the pane's pid in `diagnostics memory` and nowhere else, and `ps`
593
- * gives that pid's terminal's foreground process group (ADR 0031).
595
+ * gives that pid's terminal's foreground process group (ADR 0034).
594
596
  * On macOS the pane is `login` with the shell as its child, so the shell is in
595
597
  * front when the group is the pane's own or that of a child of a `login` pane.
596
598
  * `diagnostics memory` is a diagnostics command and may change, so everything
package/src/retire.js CHANGED
@@ -25,6 +25,7 @@ import path from 'node:path';
25
25
  import { readBook, tabIdsIn, updateBook } from './book.js';
26
26
  import { botDir, dropSession, readBot } from './bot.js';
27
27
  import { deleteProject, findProject, projects, reloadWindow, tabs } from './orca.js';
28
+ import { shellWord } from './launch.js';
28
29
  import { fleetMember } from './pause.js';
29
30
  import { closeTabs, commandLine, tabsToClose } from './restart.js';
30
31
  import { unlinkSkills } from './skills.js';
@@ -34,26 +35,57 @@ import { promptPath, sessionsOf } from './up.js';
34
35
  export const retiredDir = (bots) => path.join(bots, 'retired');
35
36
 
36
37
  /**
37
- * Retire the session `session` of the bot `bot`. Returns `{ bot, session,
38
- * closed }`, and `promptsLeft` when its prompt file could not be removed.
38
+ * Retire the session `session` of the bot `bot`, and first the temporary
39
+ * sessions it made, and theirs (#464, ADR 0033). Returns `{ bot, session,
40
+ * closed, retiredWith }`, and `promptsLeft` when its prompt file could not be
41
+ * removed. `retiredWith` holds one `{ bot, session, maker, closed }` for each
42
+ * session that went with it, deepest first, with its own `promptsLeft`.
39
43
  */
40
44
  export async function retireSession(bots, { bot, session }) {
41
45
  const home = fleetMember(bots, bot, 'retire', session);
42
- const sessions = sessionsOf(readBot(home, bot), session);
46
+ const known = readBot(home, bot);
47
+ const sessions = sessionsOf(known, session);
43
48
 
44
- const closed = await closeTabs(home, tabsToClose(bots, bot, home, sessions, { keepless: true }), bots, bot, commandLine('retire', bots, bot, session));
45
- dropSession(bots, bot, session);
49
+ const retiredWith = [];
50
+ const book = readBook(home);
51
+ const made = known.sessions.filter((one) => book.sessions[one.name]?.temporary?.maker === session);
52
+ let closed;
53
+ try {
54
+ for (const { name } of made) {
55
+ const { retiredWith: theirs, ...gone } = await retireSession(bots, { bot, session: name });
56
+ retiredWith.push(...theirs, { ...gone, maker: session });
57
+ }
46
58
 
47
- const at = new Date().toISOString();
48
- await updateBook(home, (book) => {
49
- const entry = book.sessions[session];
50
- if (entry === undefined) return;
51
- delete book.sessions[session];
52
- book.retired = [...(Array.isArray(book.retired) ? book.retired : []), { name: session, ...entry, retired: at }];
53
- });
59
+ closed = await closeTabs(home, tabsToClose(bots, bot, home, sessions, { keepless: true }), bots, bot, commandLine('retire', bots, bot, session));
60
+ dropSession(bots, bot, session);
61
+
62
+ const at = new Date().toISOString();
63
+ await updateBook(home, (book) => {
64
+ const entry = book.sessions[session];
65
+ if (entry === undefined) return;
66
+ delete book.sessions[session];
67
+ book.retired = [...(Array.isArray(book.retired) ? book.retired : []), { name: session, ...entry, retired: at }];
68
+ });
69
+ } catch (error) {
70
+ // What already went stays gone, and is named: a retire run again finds
71
+ // nothing of it left to name (#464).
72
+ if (retiredWith.length > 0) error.message = `${error.message} ${goneBefore(session, retiredWith)}`;
73
+ throw error;
74
+ }
54
75
  const left = removePrompts([promptPath(bots, bot, session)]);
55
76
 
56
- return { bot, session, closed, ...left };
77
+ return { bot, session, closed, retiredWith, ...left };
78
+ }
79
+
80
+ /**
81
+ * The sessions retired along with `session` before its own retire failed, in
82
+ * words, with any prompt file of theirs left and how to remove it.
83
+ */
84
+ function goneBefore(session, retiredWith) {
85
+ const names = retiredWith.map((gone) => `${gone.bot}/${gone.session}, a temporary session of ${gone.maker}'s`);
86
+ const left = retiredWith.flatMap((gone) => gone.promptsLeft ?? [])
87
+ .map(({ file, reason }) => ` ${file} could not be removed (${reason}), and nothing reads it. Remove it with rm ${shellWord(file)}`);
88
+ return `Retired along with ${session} before that, and still retired: ${names.join('; ')}.${left.join('')}`;
57
89
  }
58
90
 
59
91
  /**
package/src/sent.js ADDED
@@ -0,0 +1,111 @@
1
+ // `obk session sent`: what the kit's PostToolUse hook says after a session's
2
+ // native message (#451, ADR 0032).
3
+ //
4
+ // Claude Code's own messaging reaches every Claude session on the machine, not
5
+ // only the sessions of one bots folder. After a send to another session, this
6
+ // looks at where it went: to a session address none of the fleet's books name,
7
+ // the session is told so, with the road the kit would have given it. The send
8
+ // has gone either way. Nothing here decides anything about it, and anything it
9
+ // cannot read is silence (ADR 0022: a hook never disturbs the session).
10
+ //
11
+ // It reads the send from its fields, never from its wording. Seen on Claude
12
+ // Code 2.1.283: a send to an in-process teammate answers with a `routing`
13
+ // object, and one to another session without it; the input's `to` names the
14
+ // target, as a session's name with an optional ` [xxxxxx]` reference, or as a
15
+ // `uds:` socket path when answering a message, which no book can map.
16
+
17
+ import { readBook } from './book.js';
18
+ import { botDir, botNames } from './bot.js';
19
+ import { isAddressOf, ownCli, shellWord } from './launch.js';
20
+ import { lookUp } from './message.js';
21
+ import { TAB_ENV } from './record.js';
22
+
23
+ /**
24
+ * The warning for a send the hook was told about, or undefined when there is
25
+ * nothing to say. `said` is what Claude Code handed the hook.
26
+ */
27
+ export function sentWarning(bots, said) {
28
+ if (said?.tool_name !== 'SendMessage') return undefined;
29
+ const to = said.tool_input?.to;
30
+ if (typeof to !== 'string') return undefined;
31
+ const result = resultOf(said);
32
+ // A teammate in the same session, or a send that did not go: not this.
33
+ if (result === undefined || result.success !== true || 'routing' in result) return undefined;
34
+ if (/^(uds|bridge):/.test(to)) return undefined;
35
+
36
+ const name = to.replace(/\s*\[[^\]]*\]\s*$/, '').trim();
37
+ if (name === '') return undefined;
38
+ const fleet = addressesIn(bots);
39
+ if (fleet === undefined || fleet.all.has(name)) return undefined;
40
+
41
+ const meant = fleet.byName.get(name.split('.').slice(0, 2).join('.'));
42
+ const ask = (to) => `\`${shellWord(ownCli())} message to --bots ${shellWord(bots)} --to ${to}\``;
43
+ const road = meant === undefined
44
+ ? `${ask('<bot>/<session>')} gives the road to a session of this fleet.`
45
+ : roadTo(bots, meant, ask(`${meant.bot}/${meant.session}`));
46
+ return `That message went to ${name}, which is not one of the sessions of your bots folder, ${bots}: Claude Code's messaging reaches every session on this machine. ${road} Check before you send to it again.`;
47
+ }
48
+
49
+ /**
50
+ * The road to the session the sender probably meant, as `obk message to` would
51
+ * answer it from the sender's own tab: native to its address only between
52
+ * sessions of one approval class, the kit's mailbox otherwise (PRD 6.9). Where
53
+ * the sender cannot be told by its tab, the session's address and the question
54
+ * to ask, without claiming the address is the answer (#451 review).
55
+ */
56
+ function roadTo(bots, meant, ask) {
57
+ let found;
58
+ try {
59
+ found = lookUp(bots, { to: `${meant.bot}/${meant.session}`, tab: process.env[TAB_ENV] });
60
+ } catch {
61
+ found = undefined;
62
+ }
63
+ const who = `${meant.bot}/${meant.session}`;
64
+ if (found?.transport === 'native' && typeof found.address === 'string') {
65
+ return `If you meant ${who}, ${ask} gives the road from your session: write to ${found.address} with your own messaging.`;
66
+ }
67
+ if (found?.transport === 'orca' && typeof found.address === 'string') {
68
+ return `If you meant ${who}, ${ask} gives the road from your session: its mailbox, ${found.address}, by \`${shellWord(ownCli())} message send\`, not your own messaging.`;
69
+ }
70
+ return `If you meant ${who}, its session address is ${meant.address}; ${ask} says which road to take from your session.`;
71
+ }
72
+
73
+ /** What the send answered, as an object: Claude Code hands it over as one, or as its JSON. */
74
+ function resultOf(said) {
75
+ const raw = said.tool_output ?? said.tool_response;
76
+ if (raw !== null && typeof raw === 'object' && !Array.isArray(raw)) return raw;
77
+ if (typeof raw !== 'string') return undefined;
78
+ try {
79
+ const parsed = JSON.parse(raw);
80
+ return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : undefined;
81
+ } catch {
82
+ return undefined;
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Every session address the fleet's books name, retired sessions' included, and
88
+ * each live one by `<bot>.<session>`: `{ all, byName }`. Undefined when a book
89
+ * cannot be read, since an address missing from it would be warned about wrongly.
90
+ */
91
+ function addressesIn(bots) {
92
+ const all = new Set();
93
+ const byName = new Map();
94
+ try {
95
+ for (const bot of botNames(bots)) {
96
+ const book = readBook(botDir(bots, bot));
97
+ for (const [session, entry] of Object.entries(book.sessions ?? {})) {
98
+ const address = entry?.address;
99
+ if (typeof address !== 'string') continue;
100
+ all.add(address);
101
+ if (isAddressOf(bot, session, address)) byName.set(`${bot}.${session}`, { bot, session, address });
102
+ }
103
+ for (const entry of Array.isArray(book.retired) ? book.retired : []) {
104
+ if (typeof entry?.address === 'string') all.add(entry.address);
105
+ }
106
+ }
107
+ } catch {
108
+ return undefined;
109
+ }
110
+ return { all, byName };
111
+ }