@assuredloop/orca-bot-kit 0.21.0 → 0.23.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/cli.js CHANGED
@@ -17,19 +17,20 @@ import { addCommand, groomCommand, grooming, upCommand } from './groom.js';
17
17
  import { checkHealth, orcaSettingFindings } from './health.js';
18
18
  import { initBots } from './init.js';
19
19
  import { APPROVALS, HARNESSES, ownCli, shellWord, workDirOf } from './launch.js';
20
- import { checkMail, lookUp, noMailboxYet, sendMessage, sessionInTab } from './message.js';
20
+ import { checkMail, decideLeftNudges, lookUp, noMailboxYet, sendMessage, sessionInTab } from './message.js';
21
21
  import { orcaCli, orcaTrouble, RELOAD_LINE, TERMINAL_ENV } from './orca.js';
22
22
  import { allowCommand, allowIn, beyondDefaults, refuseBroad, refuseNoCodexForm, runsOnClaude, runsOnCodex, takeBack, writePermissions } from './permissions.js';
23
23
  import { pauseSessions, unpauseSessions } from './pause.js';
24
24
  import { recordSession, SHELL_ENV, TAB_ENV } from './record.js';
25
25
  import { restartSessions } from './restart.js';
26
+ import { clearSession, compactSession } from './clear.js';
26
27
  import { retireBot, retireSession } from './retire.js';
27
28
  import { sentWarning } from './sent.js';
28
29
  import { readRoster } from './roster.js';
29
30
  import { buildAgents, buildRules, CODEX_CAP } from './rules.js';
30
31
  import { addSkill, buildSkills, linkSkills, removeSkill } from './skills.js';
31
32
  import { addSource, fetchSources } from './sources.js';
32
- import { makeTemp, retireTemp, trustHooks } from './temp.js';
33
+ import { listRoles, makeTemp, optionsLine, retireTemp, trustHooks } from './temp.js';
33
34
  import { readUsage } from './usage.js';
34
35
  import { BOT_FATHER, bringUp, ownMailbox } from './up.js';
35
36
 
@@ -99,7 +100,7 @@ Usage:
99
100
  project holds a tab your book does not name, and
100
101
  moves the folder only once Orca no longer lists
101
102
  the project.
102
- obk temp make --bots <path> --name <session>
103
+ obk temp make --bots <path> --name <session> [--role <role>[:<option>]]
103
104
  (--prompt <text> | --prompt-file <path>) [--harness claude|codex]
104
105
  [--model <m>] [--effort <e>] [--context <c>]
105
106
  [--approval ${APPROVALS.join('|')}] [--extra-arg=<arg>]...
@@ -113,6 +114,19 @@ Usage:
113
114
  work/<session>, and has the task as its start
114
115
  prompt. The book records it as temporary, made by
115
116
  you.
117
+ --role takes the harness, model, effort and context
118
+ of an option of a role in the bot's temp_roles, the
119
+ role's first option when none is named, and the
120
+ role's prompt file when no task is given; a flag
121
+ still wins, and what the option leaves out is
122
+ taken as above. The session is named
123
+ <role>-<session>. A role's cap, if it has one,
124
+ counts its open sessions. The answer says where
125
+ each setting came from.
126
+ obk temp roles --bots <path>
127
+ Run in a session's own tab: list the roles of your
128
+ bot's temp_roles, each option with what it is for,
129
+ and each role's cap and open sessions.
116
130
  obk temp retire --bots <path> --name <session>
117
131
  Run in the maker's own tab: retire a temporary
118
132
  session it made, as obk retire does. It refuses a
@@ -191,6 +205,18 @@ Usage:
191
205
  one command that closes a tab, it closes only the
192
206
  tabs your book names, and it will not close one
193
207
  whose conversation the book cannot name.
208
+ obk session clear --bots <path> --bot <bot> --session <name>
209
+ Start the session on a new conversation: /clear on
210
+ Claude Code, /new on Codex. It waits up to 30 s for
211
+ the session to be idle, types nothing into a busy
212
+ one or one with a question on its screen, and
213
+ answers once the book holds the new conversation.
214
+ The kit's hook gives it its start prompt again.
215
+ obk session compact --bots <path> --bot <bot> --session <name>
216
+ Compact the session's conversation with /compact,
217
+ as safely as session clear, and answer once the
218
+ harness's record shows it, or after 5 minutes that
219
+ it is not confirmed yet.
194
220
  obk health --bots <path> [--bot <bot>]
195
221
  Say what is wrong with your setup: configuration
196
222
  that will not work, a skill that is not where its
@@ -242,6 +268,12 @@ Usage:
242
268
  Claude session's native message, it warns the
243
269
  session when the address was none of this bots
244
270
  folder's own. It never stops a message.
271
+ obk session nudge --bots <path> --bot <bot>
272
+ For the kit's own hook, not for typing: after a
273
+ Codex session's shell command, it decides the mail
274
+ nudges a send there left because it could not
275
+ tell from inside Codex's sandbox, and types each
276
+ one or says why not.
245
277
  obk session mailbox --bots <path> --bot <bot> --session <name>
246
278
  For the kit's own launch line, not for typing: run
247
279
  in the session's own tab, it gives the session its
@@ -277,13 +309,17 @@ const COMMANDS = {
277
309
  'source add': ['bots', 'name', 'repo', 'ref'],
278
310
  'session add': ['bots', 'bot', 'name'],
279
311
  'session change': ['bots', 'bot', 'session'],
312
+ 'session clear': ['bots', 'bot', 'session'],
313
+ 'session compact': ['bots', 'bot', 'session'],
280
314
  'message to': ['bots', 'to'],
281
315
  'message send': ['bots', 'to', 'subject'],
282
316
  'message check': ['bots'],
283
317
  'session record': ['bots', 'bot'],
284
318
  'session sent': ['bots', 'bot'],
319
+ 'session nudge': ['bots', 'bot'],
285
320
  'session mailbox': ['bots', 'bot', 'session'],
286
321
  'temp make': ['bots', 'name'],
322
+ 'temp roles': ['bots'],
287
323
  'temp retire': ['bots', 'name'],
288
324
  'temp trust-hooks': ['bots', 'name'],
289
325
  };
@@ -306,6 +342,7 @@ const NEEDED = {
306
342
  to: '--to <bot>/<session>: which session to write to',
307
343
  from: '--from <bot>/<session>: which session is writing',
308
344
  subject: '--subject <text>: what the message is about',
345
+ role: '--role <role>[:<option>]: which role of the bot\'s temp_roles',
309
346
  };
310
347
 
311
348
  /** The flags that name something. A name that is empty names nothing. */
@@ -360,6 +397,7 @@ async function run(argv) {
360
397
  ...Object.fromEntries(SETTINGS.map(([flag]) => [flag, { type: 'string' }])),
361
398
  'extra-arg': { type: 'string', multiple: true },
362
399
  'run-on': { type: 'string' },
400
+ role: { type: 'string' },
363
401
  json: { type: 'boolean' },
364
402
  help: { type: 'boolean', short: 'h' },
365
403
  version: { type: 'boolean' },
@@ -410,6 +448,7 @@ async function run(argv) {
410
448
  // The send hook is quiet whatever it is given, a bots folder that is not
411
449
  // there included, so it goes before anything that can complain (ADR 0032).
412
450
  if (command === SENT) return sent(path.resolve(values.bots));
451
+ if (command === NUDGE) return nudgeLeft();
413
452
 
414
453
  // One fleet, one identity, whatever spelling of its path was given (#164).
415
454
  const bots = command === 'init' ? path.resolve(values.bots) : sameFleet(path.resolve(values.bots));
@@ -477,6 +516,7 @@ const ANSWER_IT = [
477
516
  /** The commands a harness runs rather than a person: the kit's hooks. */
478
517
  const RECORD = 'session record';
479
518
  const SENT = 'session sent';
519
+ const NUDGE = 'session nudge';
480
520
 
481
521
  /**
482
522
  * What the kit's send hook says after a native message: a warning the session
@@ -495,6 +535,27 @@ function sent(bots) {
495
535
  return 0;
496
536
  }
497
537
 
538
+ /**
539
+ * What the kit's Codex hook does after a shell command: decide the nudges a
540
+ * send in this tab left for it, outside Codex's sandbox, and say to the session
541
+ * what became of each, as Codex's `additionalContext` (#350, ADR 0034). Silent
542
+ * when nothing was left, never a decision, and never anything but exit 0.
543
+ */
544
+ function nudgeLeft() {
545
+ try {
546
+ const said = JSON.parse(readFileSync(0, 'utf8'));
547
+ if (said?.hook_event_name !== 'PostToolUse') return 0;
548
+ const decided = decideLeftNudges(process.env[TAB_ENV]);
549
+ if (decided.length > 0) {
550
+ const words = decided.map((one) => `Your mail "${one.subject}" to ${one.to}: ${one.nudged ? "the kit's hook nudged its tab, from outside Codex's sandbox:" : "the kit's hook looked at its tab from outside Codex's sandbox, and"} ${nudgeLine(one, one.to).trim()}`);
551
+ process.stdout.write(`${JSON.stringify({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: words.join('\n') } })}\n`);
552
+ }
553
+ } catch {
554
+ // Nothing: a hook does not disturb the session it runs in.
555
+ }
556
+ return 0;
557
+ }
558
+
498
559
  /**
499
560
  * What the kit's hook does with what the harness told it, and what it answers.
500
561
  *
@@ -647,21 +708,41 @@ const commands = {
647
708
  const tab = callerTab(bots, true);
648
709
  refuseWhenOrcaIsDown();
649
710
  const given = { name: values.name };
650
- for (const [flag, key] of [['harness', 'harness'], ['model', 'model'], ['effort', 'effort'], ['approval', 'approval'], ['prompt', 'prompt'], ['prompt-file', 'prompt_file']]) {
711
+ for (const [flag, key] of [['harness', 'harness'], ['model', 'model'], ['effort', 'effort'], ['approval', 'approval'], ['prompt', 'prompt'], ['prompt-file', 'prompt_file'], ['role', 'role']]) {
651
712
  if (values[flag] !== undefined) given[key] = values[flag];
652
713
  }
653
714
  if (values.context !== undefined) given.context = asNumberOrText(values.context);
654
715
  if (values['extra-arg'] !== undefined) given.extra_args = values['extra-arg'];
655
716
  const made = await makeTemp(bots, { tab, ...given });
656
717
  const { tabs, rules, skills, permissions, paused, projects } = made.up;
657
- const answer = { bots, bot: made.bot, session: made.session, maker: made.maker, settings: made.settings, created: [], completed: [], rules, skills, permissions, tabs, paused, projects };
718
+ const answer = {
719
+ bots, bot: made.bot, session: made.session, maker: made.maker, ...(made.role === undefined ? {} : { role: made.role }), settings: made.settings, chosen: made.chosen,
720
+ created: [], completed: [], rules, skills, permissions, tabs, paused, projects,
721
+ };
658
722
  const retire = `${shellWord(ownCli())} temp retire --bots ${shellWord(bots)} --name ${made.session}`;
659
723
  return {
660
724
  answer,
661
- lines: tabLines(answer, `Made ${made.bot}/${made.session}, a temporary session of ${made.maker}'s, working in work/${made.session}. Retire it when its work is done: ${retire}`),
725
+ lines: [
726
+ ...(made.role === undefined ? [] : [`role ${made.role.name}:${made.role.option}${made.role.for === undefined ? '' : `, for ${made.role.for}`}. Its options: ${optionsLine(made.role.options)}`]),
727
+ ...Object.entries(made.chosen).map(([setting, one]) => `${setting.padEnd(9)} ${one.value === undefined ? `the ${one.from}` : `${one.value}, from ${one.from === 'flag' ? `--${setting}` : `the ${one.from}`}`}`),
728
+ ...tabLines(answer, `Made ${made.bot}/${made.session}, a temporary session of ${made.maker}'s, working in work/${made.session}. Retire it when its work is done: ${retire}`),
729
+ ],
662
730
  };
663
731
  },
664
732
 
733
+ 'temp roles'(bots) {
734
+ const tab = callerTab(bots, true);
735
+ const listed = listRoles(bots, { tab });
736
+ const lines = listed.roles.length === 0
737
+ ? [`${listed.bot} has no roles for temporary sessions: its bot.yaml has no temp_roles.`]
738
+ : listed.roles.flatMap((role) => [
739
+ `role ${role.name}${role.cap === undefined ? '' : `, cap ${role.cap}`}, open: ${role.open.length === 0 ? 'none' : role.open.join(', ')}${role.prompt_file === undefined ? '' : `, prompt file ${role.prompt_file}`}`,
740
+ ...role.options.map((option) => ` ${option.default ? 'default' : 'option '} ${role.name}:${option.name}${option.for === undefined ? '' : `, for ${option.for}`}: ${['harness', 'model', 'effort', 'context'].filter((key) => option[key] !== undefined).map((key) => `${key} ${option[key]}`).join(', ') || 'all as you have them'}`),
741
+ ...(role.trouble ?? []).map((says) => ` trouble ${says}`),
742
+ ]);
743
+ return { answer: { bots, ...listed }, lines };
744
+ },
745
+
665
746
  async 'temp retire'(bots, values) {
666
747
  const tab = callerTab(bots, true);
667
748
  refuseWhenOrcaIsDown();
@@ -1070,6 +1151,28 @@ const commands = {
1070
1151
  ],
1071
1152
  };
1072
1153
  },
1154
+
1155
+ async 'session clear'(bots, values) {
1156
+ refuseWhenOrcaIsDown();
1157
+ const cleared = await clearSession(bots, { bot: values.bot, session: values.session });
1158
+ const after = cleared.was === null ? '' : `, in place of ${cleared.was}`;
1159
+ return {
1160
+ answer: { bots, cleared },
1161
+ lines: [`cleared ${cleared.bot} ${cleared.session}: the book holds its new conversation ${cleared.now}${after}.`],
1162
+ };
1163
+ },
1164
+
1165
+ async 'session compact'(bots, values) {
1166
+ refuseWhenOrcaIsDown();
1167
+ const compacted = await compactSession(bots, { bot: values.bot, session: values.session });
1168
+ const what = `${compacted.bot} ${compacted.session}`;
1169
+ return {
1170
+ answer: { bots, compacted },
1171
+ lines: [compacted.confirmed
1172
+ ? `compacted ${what}: the harness's record of its conversation ${compacted.conversation} shows the compaction.`
1173
+ : `asked ${what} to compact: /compact went in, and after 5 minutes the harness's record shows no compaction, so it is not confirmed yet. Look at its tab.`],
1174
+ };
1175
+ },
1073
1176
  };
1074
1177
 
1075
1178
  /**
@@ -1107,6 +1210,9 @@ function nudgeLine(answer, where) {
1107
1210
  if (answer.blocked !== undefined) {
1108
1211
  return ` its tab has something waiting to be answered (${answer.blocked}), so nothing was typed into it. Settle that, and the mail is there.`;
1109
1212
  }
1213
+ if (answer.nudgeLeft) {
1214
+ return ` it is queued, and its tab could not be told to look from here: ${answer.nudgeTrouble}. So the nudge is left for this session's hook, which looks at the tab from outside Codex's sandbox when this command is done, and says what it did.`;
1215
+ }
1110
1216
  if (answer.nudgeTrouble !== undefined) {
1111
1217
  return ` it is queued, and its tab could not be told to look: ${answer.nudgeTrouble}`;
1112
1218
  }
package/src/health.js CHANGED
@@ -22,7 +22,7 @@ import { existsSync, readdirSync, realpathSync } from 'node:fs';
22
22
  import path from 'node:path';
23
23
 
24
24
  import { bookFile, readBook, sessionIdsIn, tabIdsIn } from './book.js';
25
- import { botDir, botNames, botsDir, readBot, unknownKeys } from './bot.js';
25
+ import { botDir, botNames, botsDir, readBot, tempRoles, unknownKeys } from './bot.js';
26
26
  import { transcriptsIn } from './conversations.js';
27
27
  import { hookTrouble } from './hooks.js';
28
28
  import { bypassFlags, harnessOf, HARNESSES, isAddressOf, ownCli, sessionTrouble, SHELL_ENV, shellWord } from './launch.js';
@@ -190,6 +190,7 @@ function aboutBot(bots, name, setups, sessions) {
190
190
  return [
191
191
  ...unknownKeys(home, name).map(said('config')),
192
192
  ...sessionSettings(home, bot).map(said('config')),
193
+ ...rolesTrouble(home, bot).map(said('config')),
193
194
  ...agentsTrouble(bots, home, bot).map(said('config')),
194
195
  ...hooksOf(bots, home, bot).map(said('config')),
195
196
  ...permissionsTrouble(home, bot).map(said('config')),
@@ -209,6 +210,16 @@ function sessionSettings(home, bot) {
209
210
  });
210
211
  }
211
212
 
213
+ /** What is wrong with the roles the bot's temporary sessions are made in (#465). */
214
+ function rolesTrouble(home, bot) {
215
+ const where = path.join(home, 'bot.yaml');
216
+ try {
217
+ return tempRoles(home, bot).flatMap((role) => role.trouble.map((says) => ({ where, says })));
218
+ } catch (error) {
219
+ return [{ where, says: error.message }];
220
+ }
221
+ }
222
+
212
223
  /** The kit's own hook, in the file of every harness this bot's sessions run on. */
213
224
  const hooksOf = (bots, home, bot) => [...new Set(bot.sessions.map((session) => harnessOf(session, bot.harness)))]
214
225
  .filter((harness) => HARNESSES.includes(harness))
@@ -311,13 +322,10 @@ function runningOn(bots, home, bot, book, handles, sessions) {
311
322
 
312
323
  if (rules.state === 'older') {
313
324
  const agents = path.join(home, 'AGENTS.md');
314
- // A clear on Claude Code reads the instructions again, as a start does
315
- // (tech notes, section 2); whether a new conversation on Codex does is
316
- // not established, so a start is all that is offered there.
317
- const how = harness === 'claude'
318
- ? `A Claude Code session reads it again at a /clear in its tab, or when it starts: ${restart}`
319
- : `A Codex session reads it when it starts: ${restart}`;
320
- 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));
325
+ // A clear reads the instructions again, as a start does, on both
326
+ // harnesses (tech notes, sections 2 and 3; #391).
327
+ const clear = `${shellWord(ownCli())} session clear --bots ${shellWord(bots)} --bot ${bot.name} --session ${session.name}`;
328
+ 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. A session reads it again when it is cleared: ${clear}, or when it starts: ${restart}`, bot.name));
321
329
  }
322
330
  }
323
331
  return found;
package/src/hooks.js CHANGED
@@ -54,6 +54,21 @@ const SENT_MATCHER = 'SendMessage';
54
54
  export const sentCommand = (bots, bot, cli = ownCli()) =>
55
55
  `${shellWord(cli)} session sent --bots ${shellWord(bots)} --bot ${shellWord(bot)} 2>/dev/null || true`;
56
56
 
57
+ /**
58
+ * Codex's second kit hook: after each command its shell tool ran, outside
59
+ * Codex's sandbox, it decides the mail nudges a send in that command could not
60
+ * (#350, ADR 0034). Codex only, since only Codex runs its commands where `ps`
61
+ * does not start. It types into another session's tab, which takes Orca a few
62
+ * seconds a nudge, so it is given longer than the others.
63
+ */
64
+ const NUDGE_EVENT = 'PostToolUse';
65
+ const NUDGE_MATCHER = 'Bash';
66
+ const NUDGE_TIMEOUT = 30;
67
+
68
+ /** What the nudge hook runs, by the kit's own path, as `hookCommand` does. */
69
+ export const nudgeCommand = (bots, bot, cli = ownCli()) =>
70
+ `${shellWord(cli)} session nudge --bots ${shellWord(bots)} --bot ${shellWord(bot)} 2>/dev/null || true`;
71
+
57
72
  /**
58
73
  * Make sure the bot at `home` has the kit's SessionStart hook for `harness`.
59
74
  * Returns the file it wrote, relative to the bot home, or undefined when the
@@ -76,8 +91,7 @@ export function installHook(home, harness, { bots, bot }) {
76
91
  // longer asks about is taken out, and a hook of the user's beside the kit's
77
92
  // stays where they put it.
78
93
  const started = withKitHook(settings.hooks, file, mine);
79
- const sentHook = { type: 'command', command: sentCommand(bots, bot), timeout: TIMEOUT };
80
- const wanted = { ...settings, hooks: harness === 'claude' ? withSentHook(started, sentHook) : started };
94
+ const wanted = { ...settings, hooks: withToolHook(started, toolHookOf(harness, bots, bot)) };
81
95
 
82
96
  // Compared as documents, not as text: how the user laid their file out is
83
97
  // theirs, and a run that changes nothing writes nothing.
@@ -124,24 +138,25 @@ export function hookTrouble(home, harness, { bots, bot }) {
124
138
  }
125
139
  const program = running ?? ownCli();
126
140
  wanted = { ...settings, hooks: withKitHook(settings.hooks, file, { type: 'command', command: hookCommand(bots, bot, program), timeout: TIMEOUT }) };
127
- // The send hook is judged on its own: the session hook may be right while
128
- // the warning after a native message is missing (ADR 0032).
129
- if (harness === 'claude' && isDeepStrictEqual(settings, wanted)) {
130
- const sentRunning = sentProgramIn(settings);
131
- // A send hook that runs a kit no longer there is as quiet as a missing
132
- // one: `|| true` hides it, and no warning is ever given (#451 review).
133
- if (sentRunning !== undefined && sentRunning !== BARE && !existsSync(sentRunning)) {
141
+ // The harness's second hook is judged on its own: the session hook may be
142
+ // right while the warning after a native message (ADR 0032), or the nudge
143
+ // a Codex sender left (ADR 0034), is missing.
144
+ if (isDeepStrictEqual(settings, wanted)) {
145
+ const tool = toolHookOf(harness, bots, bot);
146
+ const toolRunning = programIn(settings, tool.pattern);
147
+ // One that runs a kit no longer there is as quiet as a missing one:
148
+ // `|| true` hides it (#451 review).
149
+ if (toolRunning !== undefined && toolRunning !== BARE && !existsSync(toolRunning)) {
134
150
  return {
135
151
  where: file,
136
- says: `${file} holds the kit's PostToolUse hook for SendMessage, but it runs ${sentRunning}, which is not there any more, so a session is never warned when its native message goes outside this bots folder (ADR 0032). obk up puts back one that runs the kit you have.`,
152
+ says: `${file} holds the kit's ${tool.event} hook for ${tool.matcher}, but it runs ${toolRunning}, which is not there any more, so ${tool.missing}. obk up puts back one that runs the kit you have.`,
137
153
  };
138
154
  }
139
- const sentProgram = sentRunning ?? program;
140
- const withSent = { ...settings, hooks: withSentHook(settings.hooks, { type: 'command', command: sentCommand(bots, bot, sentProgram), timeout: TIMEOUT }) };
141
- if (!isDeepStrictEqual(settings, withSent)) {
155
+ const withTool = { ...settings, hooks: withToolHook(settings.hooks, toolHookOf(harness, bots, bot, toolRunning ?? program)) };
156
+ if (!isDeepStrictEqual(settings, withTool)) {
142
157
  return {
143
158
  where: file,
144
- says: `${file} does not hold the kit's PostToolUse hook for SendMessage, which warns a session when its native message went outside this bots folder (ADR 0032). obk up puts it back.`,
159
+ says: `${file} does not hold the kit's ${tool.event} hook for ${tool.matcher}, without which ${tool.missing}. obk up puts it back.`,
145
160
  };
146
161
  }
147
162
  }
@@ -216,21 +231,46 @@ function withKitHook(hooks, file, mine) {
216
231
  }
217
232
 
218
233
  /**
219
- * The hooks with the kit's send hook in a `PostToolUse` group matched to
220
- * `SendMessage`, and everything else as the user left it, as `withKitHook`
221
- * keeps the session hook: one entry of the kit's, written in place where it
222
- * already sits in such a group, taken out of anywhere else, and added in a
223
- * group of its own when there is none. A group the kit empties goes.
234
+ * Each harness's second kit hook, after one of its tools: Claude Code's after a
235
+ * native `SendMessage` (ADR 0032), Codex's after its shell tool (ADR 0034).
236
+ * The entry, where it goes, how the kit knows its own, and what is lost
237
+ * without it.
224
238
  */
225
- function withSentHook(hooks, mine) {
239
+ function toolHookOf(harness, bots, bot, cli = ownCli()) {
240
+ if (harness === 'claude') {
241
+ return {
242
+ event: SENT_EVENT,
243
+ matcher: SENT_MATCHER,
244
+ pattern: KIT_SENT,
245
+ mine: { type: 'command', command: sentCommand(bots, bot, cli), timeout: TIMEOUT },
246
+ missing: 'a session is never warned when its native message goes outside this bots folder (ADR 0032)',
247
+ };
248
+ }
249
+ return {
250
+ event: NUDGE_EVENT,
251
+ matcher: NUDGE_MATCHER,
252
+ pattern: KIT_NUDGE,
253
+ mine: { type: 'command', command: nudgeCommand(bots, bot, cli), timeout: NUDGE_TIMEOUT },
254
+ missing: "mail a session sends from inside Codex's sandbox never tells a receiver the kit could not see from there, such as a Claude session busy with a command (ADR 0034)",
255
+ };
256
+ }
257
+
258
+ /**
259
+ * The hooks with the kit's tool hook in a group of its event matched to its
260
+ * tool, and everything else as the user left it, as `withKitHook` keeps the
261
+ * session hook: one entry of the kit's, written in place where it already sits
262
+ * in such a group, taken out of anywhere else, and added in a group of its own
263
+ * when there is none. A group the kit empties goes.
264
+ */
265
+ function withToolHook(hooks, { event: wantedEvent, matcher, pattern, mine }) {
226
266
  let placed = false;
227
267
  const events = Object.fromEntries(Object.entries(hooks).flatMap(([event, groups]) => {
228
268
  if (!Array.isArray(groups)) return [[event, groups]];
229
269
  const kept = groups.flatMap((group) => {
230
270
  if (!Array.isArray(group?.hooks)) return [group];
231
271
  const entries = group.hooks.flatMap((hook) => {
232
- if (!isSentHook(hook)) return [hook];
233
- if (event !== SENT_EVENT || group.matcher !== SENT_MATCHER || placed) return [];
272
+ if (programOf(hook?.command, pattern) === undefined) return [hook];
273
+ if (event !== wantedEvent || group.matcher !== matcher || placed) return [];
234
274
  placed = true;
235
275
  return [mine];
236
276
  });
@@ -239,27 +279,24 @@ function withSentHook(hooks, mine) {
239
279
  return kept.length === 0 ? [] : [[event, kept]];
240
280
  }));
241
281
  if (placed) return events;
242
- return { ...events, [SENT_EVENT]: [...(events[SENT_EVENT] ?? []), { matcher: SENT_MATCHER, hooks: [mine] }] };
282
+ return { ...events, [wantedEvent]: [...(events[wantedEvent] ?? []), { matcher, hooks: [mine] }] };
243
283
  }
244
284
 
245
-
246
- /** Whether a hook entry is the kit's send hook, run by a kit, for any bots folder and bot. */
247
- const isSentHook = (hook) => sentProgramOf(hook?.command) !== undefined;
248
-
249
- function sentProgramOf(command) {
250
- const found = typeof command === 'string' ? KIT_SENT.exec(command) : null;
285
+ /** The program a command of the kit's of this pattern runs, unquoted, or undefined for any other command. */
286
+ function programOf(command, pattern) {
287
+ const found = typeof command === 'string' ? pattern.exec(command) : null;
251
288
  if (found === null) return undefined;
252
289
  const program = unquoted(found[1]);
253
290
  return isKitCli(program) ? program : undefined;
254
291
  }
255
292
 
256
- /** What the kit's send hook in these settings runs, or undefined when there is none. */
257
- function sentProgramIn(settings) {
293
+ /** What the kit's hook of this pattern in these settings runs, or undefined when there is none. */
294
+ function programIn(settings, pattern) {
258
295
  for (const groups of Object.values(settings.hooks ?? {})) {
259
296
  if (!Array.isArray(groups)) continue;
260
297
  for (const group of groups) {
261
298
  for (const hook of Array.isArray(group?.hooks) ? group.hooks : []) {
262
- const program = sentProgramOf(hook?.command);
299
+ const program = programOf(hook?.command, pattern);
263
300
  if (program !== undefined) return program;
264
301
  }
265
302
  }
@@ -282,6 +319,7 @@ const isKitHook = (hook) => kitProgramOf(hook?.command) !== undefined;
282
319
  const WORD = String.raw`(?:[A-Za-z0-9,._+:@%/=-]+|'(?:[^']|'\\'')*')`;
283
320
  const KIT_HOOK = new RegExp(String.raw`^(${WORD}) session record --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
284
321
  const KIT_SENT = new RegExp(String.raw`^(${WORD}) session sent --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
322
+ const KIT_NUDGE = new RegExp(String.raw`^(${WORD}) session nudge --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
285
323
 
286
324
  /** The program a bot made before the kit named itself by path runs. */
287
325
  const BARE = 'obk';
package/src/lines.js CHANGED
@@ -25,18 +25,21 @@ const LONGEST = 64 * 1024 * 1024;
25
25
  /**
26
26
  * Each line of `file`, in order, to `onLine`: its text, or `undefined` for a
27
27
  * line too long to hold. A line `onLine` answers `true` to is the last one
28
- * read. Throws what the file system throws when the file cannot be opened or
29
- * read.
28
+ * read. With `from`, reading starts at that byte, for a caller that wants only
29
+ * what was written after it (#391). Throws what the file system throws when
30
+ * the file cannot be opened or read.
30
31
  */
31
- export function eachLine(file, onLine) {
32
+ export function eachLine(file, onLine, { from = 0 } = {}) {
32
33
  const fd = openSync(file, 'r');
33
34
  try {
34
35
  const decoder = new StringDecoder('utf8');
35
36
  const buffer = Buffer.allocUnsafe(CHUNK);
36
37
  let pending = '';
37
38
  let tooLong = false;
39
+ let position = from;
38
40
  for (;;) {
39
- const read = readSync(fd, buffer, 0, CHUNK, null);
41
+ const read = readSync(fd, buffer, 0, CHUNK, position);
42
+ position += read;
40
43
  const text = read === 0 ? decoder.end() : decoder.write(buffer.subarray(0, read));
41
44
  let start = 0;
42
45
  for (let at = text.indexOf('\n'); at !== -1; at = text.indexOf('\n', start)) {
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