@assuredloop/orca-bot-kit 0.7.0 → 0.9.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/README.md CHANGED
@@ -431,6 +431,31 @@ retiring it again finishes the job. Bot
431
431
  Father itself, and its management session `daily`, are never paused or
432
432
  retired; its other sessions are like any bot's.
433
433
 
434
+ ## Daily grooming
435
+
436
+ Grooming is an optional daily pass over the fleet: what each bot did, what it
437
+ cost, and what to improve, sent as one short report to Bot Father's management
438
+ session. It runs in a session of Bot Father's called `grooming`, on Claude
439
+ Code's own scheduler, at the model and effort you give that session.
440
+
441
+ ```sh
442
+ obk session add --bots /path/to/my-bots --bot bot-father --name grooming --model sonnet --effort medium
443
+ obk up --bots /path/to/my-bots --bot bot-father
444
+ obk groom --bots /path/to/my-bots --now # run it once, by hand, and read it
445
+ obk groom --bots /path/to/my-bots --on --at 04:00 # then schedule it
446
+ obk groom --bots /path/to/my-bots # what is scheduled
447
+ ```
448
+
449
+ It starts off, and Bot Father asks you before it turns it on, because it spends
450
+ tokens every day. It fires only while its tab is open in Orca and Claude Code is
451
+ idle there, up to half an hour after its time, and a day it misses is not made
452
+ up. Each run renews its own schedule, which Claude Code would otherwise end
453
+ after a week, and `--on --at` puts it back if it lapses. A `/clear` in its tab
454
+ leaves it running until the session is next restarted; after that, `--on --at`
455
+ puts it back.
456
+ `obk groom --compact` compacts its conversation between runs. Grooming on Codex
457
+ comes later: on a Codex Bot Father, add the session with `--harness claude`.
458
+
434
459
  ## When something is wrong
435
460
 
436
461
  ```sh
@@ -504,7 +529,8 @@ name. Everything else goes through Orca's mailbox, which the kit does carry.
504
529
 
505
530
  That includes a Claude session still running under a name from before the
506
531
  token, `<bot>.<session>`: `message to` answers the mailbox road for it, never
507
- that name, until the session next starts a new conversation.
532
+ that name, until the kit's launch line next starts it (`obk restart`, or
533
+ `obk up` after its tab is gone), which gives it a token name of its own.
508
534
 
509
535
  A session's mailbox is made the first time the kit starts it, and written in
510
536
  the book beside its tab. It is an Orca Run rather than the session's tab,
@@ -573,7 +599,9 @@ cannot be read as one that passed. `-- --yes` is how you say you meant it. When
573
599
  Orca is not answering, the command says which Orca it asked and skips, rather
574
600
  than report a kit that is not broken. Name files after it to run only those;
575
601
  each must be one of the `*.test.js` files under `test/system/`, links followed,
576
- and a name that is not is refused before anything runs.
602
+ and a name that is not is refused before anything runs. This command is the
603
+ only way in: a system test loaded any other way, with `node --test` or an
604
+ editor's runner, skips and says how to run it.
577
605
 
578
606
  `npm run mutate` runs [StrykerJS](https://stryker-mutator.io) over the
579
607
  JavaScript this branch changed against `main` — committed, still in the working
package/SETUP.md CHANGED
@@ -37,7 +37,7 @@ node --version # 24.21.0 or newer
37
37
  git --version
38
38
  /Applications/Orca.app/Contents/Resources/bin/orca status --json
39
39
  claude --version # at least one of these
40
- codex --version
40
+ codex --version # 0.156.1 or newer
41
41
  ```
42
42
 
43
43
  The Orca line must come back with `ok: true` and `result.runtime.reachable:
@@ -55,6 +55,10 @@ first release measured here that loads it without an experimental warning.
55
55
  If neither harness is installed, say so and stop. If only one is, that is fine,
56
56
  and it is the one Bot Father will run on.
57
57
 
58
+ If Codex is older than 0.156.1, say so and ask them to update it before a bot
59
+ runs on Codex. The kit starts Codex with `--no-daemon`, and older Codex refuses
60
+ that flag.
61
+
58
62
  ## 2. Make `obk` runnable
59
63
 
60
64
  **First ask whether it already is:**
@@ -206,8 +210,9 @@ empty book and nothing says why.
206
210
  Codex's trust question applies to the **repository root**, which for a bot means
207
211
  the whole bots folder rather than the one bot. That is what they are agreeing to.
208
212
 
209
- Codex's `/new` question (0.156.1 on) is always answered with the current
210
- checkout, which is the bot home. The kit never makes a git worktree, so
213
+ Codex's `/new` question (seen on 0.156.1; 0.157.1 did not show it in the runs
214
+ seen so far) is always answered with the current checkout, which is the bot
215
+ home. The kit never makes a git worktree, so
211
216
  `2. New worktree` is never the answer.
212
217
 
213
218
  **Anything you do not recognise: type nothing.** Tell them what is on the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assuredloop/orca-bot-kit",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -260,9 +260,10 @@ table: a harness that has added an option has moved them. Codex's hooks
260
260
  question matters most. The kit's hook is how the book learns which
261
261
  conversation the session is running, and until it is answered the
262
262
  conversation has not started. Codex's trust applies to the repository root,
263
- which is the whole bots folder. Codex's `/new` asks where the new
264
- conversation runs; the answer is always the current checkout, the bot home,
265
- and never `2. New worktree`: the kit never makes a git worktree.
263
+ which is the whole bots folder. Codex's `/new` may ask where the new
264
+ conversation runs (0.156.1 did; 0.157.1 has not so far); the answer is
265
+ always the current checkout, the bot home, and never `2. New worktree`:
266
+ the kit never makes a git worktree.
266
267
 
267
268
  Where the kit says no session came up, the shell swallowed the launch line,
268
269
  usually while it was asking its own question. Answer the shell, then close
@@ -50,11 +50,13 @@ profile notes and that any bot may read them; and the routing, where something
50
50
  the kit does wrong is filed as an issue in one step and something a bot does
51
51
  wrong goes back to that bot's session as feedback.
52
52
 
53
- That grooming is the automation's own session rather than a tab in the book,
54
- and that what it must remember therefore lives in files, follows from a fact
55
- measured for this slice: an Orca automation cannot reuse a session it did not
56
- start, so it cannot be pointed at a tab the kit made. It is written down in
57
- the tech notes.
53
+ Grooming runs in a session in the book, on Claude Code's own scheduler,
54
+ because the owner decided on 2026-09-24 (#223) that scheduled work does not use
55
+ an Orca automation: an automation cannot carry a model or an effort of its
56
+ own. Its runs share one long conversation, which the owner accepted in the
57
+ same decision ("just do /compact when needed, add to the skill"). What a run
58
+ must remember still lives in files, because a compacted conversation keeps
59
+ only a summary. The facts about Claude Code's scheduler are in the tech notes.
58
60
 
59
61
  ## Ideas taken from material with no licence
60
62
 
@@ -109,3 +111,17 @@ Sources and licences in full: [LICENSES.md](LICENSES.md).
109
111
  expected). The load limit and where the memory lives are the tech notes'
110
112
  facts, verified live on Claude Code 2.1.280; Codex's store is the user's and
111
113
  is left alone.
114
+
115
+ ## What moving to Claude Code's scheduler changed (#237)
116
+
117
+ - Where it remembers: the runs share one conversation now, so the files are
118
+ kept because a compact leaves a summary and a `/clear` leaves nothing, rather
119
+ than because each run starts fresh.
120
+ - Turning it on: a `grooming` session in the book with its own model and
121
+ effort, a run by hand with `obk groom --now`, then `--on --at` after the yes.
122
+ That it fires only while its tab is up, the week-long limit each run renews,
123
+ and what a `/clear` does are Claude Code's documented behaviour, in the tech
124
+ notes.
125
+ - Compacting is new, from the owner's decision on #223. Who types `/compact`
126
+ (the management session, through the kit) is the architect's reading of it on
127
+ #237.
@@ -53,11 +53,13 @@ say what was and was not looked at rather than taking the report's word for it.
53
53
 
54
54
  ## Where it remembers
55
55
 
56
- Each run is a fresh conversation, so everything it knows from the last one is
57
- in files in the bots folder: the window and the open findings in a `grooming/`
58
- folder unless the project already keeps them elsewhere, and the profile notes
59
- in `profiles/`, where the fleet's rules tell every bot to look. Say at the top
60
- of each what it is. A run that cannot find
56
+ The runs share one long conversation, and a compacted conversation keeps a
57
+ summary of the earlier runs, not their detail; a `/clear` keeps nothing. So
58
+ everything a run needs from the last one is in files in the bots folder: the
59
+ window and the open findings in a `grooming/` folder unless the project already
60
+ keeps them elsewhere, and the profile notes in `profiles/`, where the fleet's
61
+ rules tell every bot to look. Say at the top of each what it is. Trust the
62
+ files over what the conversation seems to remember. A run that cannot find
61
63
  them says so and reads less, not everything.
62
64
 
63
65
  The open findings are what stops the same thing being reported every day:
@@ -213,11 +215,45 @@ places and sending one to the other loses it.
213
215
 
214
216
  ## Turning it on
215
217
 
216
- Do not schedule a pass nobody has watched. Run it by hand once, read what it
217
- produced, and fix the run rather than the report. So `obk groom --at <HH:MM>`
218
- creates it off, and `obk groom --on` turns it on after one explicit yes from
219
- the user who has read a run. Tell them what it will cost them each day before
220
- they say it. Make it through that command rather than by hand, so there is only
221
- ever one.
218
+ The pass runs in a session of Bot Father's own, `grooming`, on Claude Code's
219
+ own scheduler. Add it like any session, with the model and effort it is to run
220
+ at: `obk session add --bots <folder> --bot bot-father --name grooming --model
221
+ <m> --effort <e>`, with `--harness claude` where Bot Father runs on Codex, then
222
+ `obk up`. Its launch line carries them, so they are what every run uses.
223
+
224
+ Do not schedule a pass nobody has watched. `obk groom --now` runs it once in its
225
+ tab; read what it produced, and fix the run rather than the report. Then, after
226
+ one explicit yes from the user who has read a run, `obk groom --on --at <HH:MM>`
227
+ schedules it. Tell them what it will cost them each day before they say it.
228
+ Schedule it through that command rather than by hand, so there is one job and
229
+ the kit can see it. `obk groom` says what is scheduled, and says so plainly
230
+ when there is none or more than one.
231
+
232
+ It fires only while its tab is open in Orca and Claude Code is idle in it, up to
233
+ half an hour after its time, and a day it misses is not made up. Tell the user
234
+ that before they rely on it. Claude Code ends a recurring job a week after it
235
+ was made, so each run renews its own; a week with no run ends it, and then
236
+ `obk groom --on --at <HH:MM>` puts back what the user agreed to. A `/clear` in
237
+ its tab leaves it running: the job belongs to the running Claude Code, not to
238
+ one conversation. It keeps running until the session is next restarted or
239
+ Orca restores its tab: a resume brings back only the jobs of the conversation
240
+ it resumes, so after a `/clear` and then a restart the job is gone. `obk groom`
241
+ says so, and `--on --at` puts it back.
242
+
243
+ ## Compacting
244
+
245
+ The runs share one conversation and each adds to it, so each run carries more
246
+ than the last and costs more. A compact shrinks it to a summary. The run cannot
247
+ do it: a scheduled run cannot type a built-in command, and a session cannot
248
+ compact itself. So the report says when one is due, and the management session
249
+ does it between runs with `obk groom --compact`.
250
+
251
+ One is due when the run's calls carry about half the model's context window.
252
+ `obk usage --bots <folder> --bot bot-father --session grooming --since <this
253
+ run's start>` gives the run's calls and tokens: input, cache read and cache
254
+ write together, divided by the calls, is roughly what each call carried.
255
+ Claude Code also compacts on its own near the limit. That is the backstop, not
256
+ the plan: it comes when the conversation is full, most likely in the middle of
257
+ a run.
222
258
 
223
259
  Sources and licences: [NOTICE.md](NOTICE.md).
package/src/book.js CHANGED
@@ -122,9 +122,7 @@ export async function updateBook(home, change) {
122
122
  /**
123
123
  * The right to change what belongs to one bot, held from before it is read until
124
124
  * after it is written — a SQLite write transaction on a file of its own beside
125
- * the book. The book is what it usually guards, and `obk groom` takes it too,
126
- * for the one thing Orca will not do for us: look for an automation and make one
127
- * if it is missing, without a second run doing the same between the two.
125
+ * the book.
128
126
  *
129
127
  * SQLite is in Node itself and does this with the operating system's own file
130
128
  * locks. That matters for one reason: the lock belongs to the process, so it is
@@ -142,7 +140,7 @@ export async function updateBook(home, change) {
142
140
  * The lock file holds no data — it is opened, locked and closed — so it stays
143
141
  * empty and leaves no journal beside it.
144
142
  */
145
- export function takeLock(home) {
143
+ function takeLock(home) {
146
144
  const file = lockFile(home);
147
145
  mkdirSync(path.dirname(file), { recursive: true });
148
146
 
package/src/cli.js CHANGED
@@ -13,7 +13,7 @@ import { fileURLToPath } from 'node:url';
13
13
  import { parseArgs } from 'node:util';
14
14
 
15
15
  import { addSession, changeBot, changeSession, createBot, leadsOutside, readBot, SESSION_FIELDS } from './bot.js';
16
- import { grooming } from './groom.js';
16
+ 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';
@@ -164,13 +164,18 @@ Usage:
164
164
  with its settings, its tab and the conversation it
165
165
  is in. It reads your files and reports them as they
166
166
  stand; what to make of them is yours.
167
- obk groom --bots <path> [--at <HH:MM>] [--on | --off]
168
- Say whether the daily grooming exists, when it runs
169
- and whether it is on, and set it up or change it
170
- when you ask. --at makes one, in Bot Father's Orca
171
- project. It is made off, because it spends tokens
172
- every day: run it by hand once, read what it gives
173
- you, then --on.
167
+ obk groom --bots <path> [--on [--at <HH:MM>] | --at <HH:MM> | --off | --now | --compact]
168
+ Say what the daily grooming is: Bot Father's session
169
+ grooming, and the job scheduled in it on Claude
170
+ Code's own scheduler. Add that session like any
171
+ other, with the model and effort each run is to use.
172
+ --now asks it to run the grooming once. --on --at
173
+ asks it to schedule it daily, and each run renews
174
+ it; it starts off, because it spends tokens every
175
+ day, so run it by hand once and read it first. --at
176
+ moves it, --off unschedules it, and --compact
177
+ compacts its conversation between runs. It fires
178
+ only while its tab is up in Orca.
174
179
  obk usage --bots <path> [--bot <bot>] [--session <name>] [--since <time>] [--until <time>]
175
180
  Say what your sessions have used: the conversations
176
181
  each one had, their calls and tokens, the models and
@@ -281,6 +286,8 @@ async function run(argv) {
281
286
  at: { type: 'string' },
282
287
  on: { type: 'boolean' },
283
288
  off: { type: 'boolean' },
289
+ now: { type: 'boolean' },
290
+ compact: { type: 'boolean' },
284
291
  skill: { type: 'string' },
285
292
  repo: { type: 'string' },
286
293
  ref: { type: 'string' },
@@ -355,7 +362,7 @@ async function run(argv) {
355
362
  /**
356
363
  * The bots folder as the file system knows it. A folder reached through a
357
364
  * symlink is the same fleet: the harnesses file their transcripts under the real
358
- * path, Orca records an automation's workspace by it, and what the kit keeps
365
+ * path, and what the kit keeps
359
366
  * beside the folder (the skill sources, the start prompts, the messages) is
360
367
  * named after it. Asked by one spelling and then another, a fleet would
361
368
  * otherwise have two of each, and a pin set through one is undone through the
@@ -658,11 +665,16 @@ const commands = {
658
665
 
659
666
  groom(bots, values) {
660
667
  refuseWhenOrcaIsDown();
661
- if (values.on === true && values.off === true) {
662
- throw new Error('groom takes --on or --off, and got both. Say which one you want.');
668
+ const asks = ['on', 'off', 'now', 'compact'].filter((flag) => values[flag] === true);
669
+ if (asks.length > 1) {
670
+ throw new Error(`groom asks for one thing at a time, and got ${asks.map((flag) => `--${flag}`).join(' and ')}. Say which one you want.`);
663
671
  }
664
- const on = values.on === true ? true : (values.off === true ? false : undefined);
665
- const groom = grooming(bots, { at: values.at, on });
672
+ if (values.at !== undefined && asks.length === 1 && asks[0] !== 'on') {
673
+ throw new Error(`--at is the time grooming runs at, and goes with --on, not with --${asks[0]}.`);
674
+ }
675
+ // --at on its own moves grooming that is on to another time.
676
+ const ask = asks[0] ?? (values.at === undefined ? undefined : 'move');
677
+ const groom = grooming(bots, { at: values.at, ask });
666
678
  return { answer: { bots, groom }, lines: groomLines(groom, bots) };
667
679
  },
668
680
 
@@ -1023,26 +1035,81 @@ function conversationLine(one) {
1023
1035
  }
1024
1036
 
1025
1037
  /**
1026
- * What there is to say about the daily grooming: whether it exists, when it
1027
- * runs, and whether it is on. A grooming that is off is not a fault, so this
1028
- * says what is there and what the next step would be rather than warning.
1038
+ * What there is to say about the daily grooming: the session it runs in, the
1039
+ * jobs scheduled there, and what this run asked of it. Grooming that is off is
1040
+ * not a fault, so this says what is there and what the next step would be;
1041
+ * no job where one was expected, or more than one, is said plainly, because
1042
+ * either means the fleet is groomed a different number of times a day than
1043
+ * anyone decided.
1029
1044
  */
1030
- function groomLines(groom, bots) {
1031
- if (!groom.exists) {
1045
+ function groomLines({ session, jobs, asked }, bots) {
1046
+ const head = 'groom'.padEnd(9);
1047
+ const more = ' '.repeat(11);
1048
+ const said = asked === null ? [] : [ASKED[asked]];
1049
+
1050
+ if (session === null) {
1051
+ return [
1052
+ ...said,
1053
+ `${head} off: Bot Father has no session called grooming, which is where it runs`,
1054
+ `${more}It runs in that session, on Claude Code's own scheduler. Give the session`,
1055
+ `${more}--model and --effort for what each run is to use.`,
1056
+ `Add it: ${addCommand(bots)}`,
1057
+ `Then: ${upCommand(bots)}`,
1058
+ ];
1059
+ }
1060
+ if (session.harness !== 'claude') {
1032
1061
  return [
1033
- `${'groom'.padEnd(9)} there is no daily grooming yet`,
1034
- `Make one: ${shellWord(ownCli())} groom --bots ${shellWord(bots)} --at 04:00`,
1062
+ ...said,
1063
+ `${head} off: Bot Father's grooming session runs on ${session.harness}, and grooming runs`,
1064
+ `${more}on Claude Code's own scheduler, so it has to be a Claude Code session: retire`,
1065
+ `${more}it and add it again with --harness claude.`,
1035
1066
  ];
1036
1067
  }
1037
1068
 
1069
+ const lines = [...said];
1070
+ if (!session.up) {
1071
+ lines.push(`${head} Bot Father's grooming session is not up, so nothing fires`);
1072
+ lines.push(`Bring it up: ${upCommand(bots)}`);
1073
+ }
1074
+ if (asked === null) lines.push(...jobLines(jobs, bots, session.up ? head : more));
1075
+ lines.push(`${more}It fires only while its tab is up in Orca and Claude Code is idle there, up to`);
1076
+ lines.push(`${more}half an hour after its time, and a run it misses is not made up.`);
1077
+ return lines;
1078
+ }
1079
+
1080
+ /** What is scheduled in the grooming session's conversation. */
1081
+ function jobLines(jobs, bots, head) {
1082
+ const more = ' '.repeat(11);
1083
+ if (jobs.length === 0) {
1084
+ return [
1085
+ `${head} off: its conversation has no grooming job`,
1086
+ `${more}It spends tokens every day. Run it by hand first: ${groomCommand(bots)} --now`,
1087
+ `Turn it on, once the user has read a run and said yes: ${groomCommand(bots)} --on --at 04:00`,
1088
+ ];
1089
+ }
1090
+ if (jobs.length === 1) {
1091
+ const [job] = jobs;
1092
+ return [
1093
+ `${head} on, daily at ${job.at ?? job.cron} job ${job.id}`,
1094
+ `${more}Each run renews it; with no run, Claude Code ends it at ${job.expires}.`,
1095
+ `Turn it off: ${groomCommand(bots)} --off`,
1096
+ ];
1097
+ }
1098
+ const times = [...new Set(jobs.map((job) => job.at))];
1038
1099
  return [
1039
- `${'groom'.padEnd(9)} daily at ${groom.at} ${groom.enabled ? 'on' : 'off'}`,
1040
- groom.enabled
1041
- ? `It runs every day at ${groom.at} and spends tokens each time. Turn it off with: ${shellWord(ownCli())} groom --bots ${shellWord(bots)} --off`
1042
- : `It is not running yet. Try it by hand, read what it gives you, then: ${shellWord(ownCli())} groom --bots ${shellWord(bots)} --on`,
1100
+ `${head} ${jobs.length} grooming jobs, so the fleet is groomed ${jobs.length} times a day: ${jobs.map((job) => `${job.id} at ${job.at ?? job.cron}`).join(', ')}`,
1101
+ `Back to one: ${groomCommand(bots)} --on${times.length === 1 && times[0] !== undefined ? '' : ' --at <HH:MM>'}`,
1043
1102
  ];
1044
1103
  }
1045
1104
 
1105
+ /** What each ask typed into the grooming tab, as the line that says so. */
1106
+ const ASKED = {
1107
+ on: `${'asked'.padEnd(9)} the grooming session to schedule the grooming, in a line typed into its tab. It is listed once the session has done it.`,
1108
+ off: `${'asked'.padEnd(9)} the grooming session to delete its grooming jobs, in a line typed into its tab.`,
1109
+ now: `${'asked'.padEnd(9)} the grooming session to run the grooming once, now. Its report goes to Bot Father's management session.`,
1110
+ compact: `${'asked'.padEnd(9)} the grooming session to compact its conversation: /compact was typed into its tab.`,
1111
+ };
1112
+
1046
1113
  /** The settings a session carries, in the order a session is written down. */
1047
1114
  const SHOWN = SESSION_FIELDS.filter((field) => field !== 'name' && field !== 'harness');
1048
1115
 
@@ -49,10 +49,16 @@ export function conversationsIn(harness, home, since) {
49
49
  * is opened, however many conversations the machine has.
50
50
  */
51
51
  export function hasConversation(harness, home, id) {
52
- if (harness === 'claude') return existsSync(path.join(claudeDir(home), `${id}.jsonl`));
52
+ if (harness === 'claude') return existsSync(claudeTranscript(home, id));
53
53
  return rollouts(codexDir()).some((file) => path.basename(file).endsWith(`-${id}.jsonl`));
54
54
  }
55
55
 
56
+ /**
57
+ * Where Claude Code keeps the conversation `id` of this bot home, whether or
58
+ * not it has written it yet.
59
+ */
60
+ export const claudeTranscript = (home, id) => path.join(claudeDir(home), `${id}.jsonl`);
61
+
56
62
  /**
57
63
  * The same conversations, each with the file the harness keeps it in, for a
58
64
  * caller that has to read what is inside one rather than only know it is there,
package/src/groom.js CHANGED
@@ -1,145 +1,312 @@
1
1
  // The daily grooming run (PRD 6.8): an optional pass that reads what the bots
2
2
  // have been doing, works out what it cost, and tells Bot Father.
3
3
  //
4
- // It is an Orca automation rather than a tab in the book, and that is forced by
5
- // how Orca works rather than chosen: an automation can only reuse a session it
6
- // started itself, so it cannot be pointed at a tab the kit made (tech notes,
7
- // section 1). So grooming is the automation's own session, and whatever it has
8
- // to remember between runs lives in files, which is what the profile notes are.
4
+ // It runs on Claude Code's own scheduler, in a session of Bot Father's called
5
+ // `grooming`, because the owner decided that scheduled work does not use an
6
+ // Orca automation: an automation cannot carry a model or an effort of its own,
7
+ // and a session's launch line can (#223). The user adds that session like any
8
+ // other, with the model and effort it is to run at, and brings it up; the kit
9
+ // never makes, starts or closes it.
9
10
  //
10
- // It is created off. It spends tokens every day, and a routine nobody has
11
- // watched should not be running unwatched, so it waits for one explicit yes.
11
+ // So the kit schedules nothing itself. The job is the session's: its own
12
+ // `CronCreate`, which lives in the running Claude Code and only while its tab is
13
+ // up, comes back on a resume, and outlives a `/clear` (tech notes, section 2).
14
+ // The kit reads which grooming jobs there are out of the transcripts of the
15
+ // conversations that process has had, and when asked it types one line into the
16
+ // grooming tab: schedule, unschedule, run once, or compact. It keeps no copy of
17
+ // what is scheduled, so what it reports is what the conversation holds.
12
18
  //
13
- // The one thing this must get right is that there is never more than one.
14
- // Orca does not deduplicate an automation by name: asked twice, it makes two,
15
- // and a user with two of these is groomed twice a day for ever. So the kit
16
- // looks for its own before it creates anything, and the looking is by both the
17
- // name it gives its own and the folder it attached it to, because a user's own
18
- // automation can live in the same project and must be left alone.
19
+ // A line typed into the tab is run when the session gets to it, which may be
20
+ // after the kit has typed another, so what the kit read from the transcript
21
+ // when it typed can be out of date: an `--on` not yet run leaves no job to see.
22
+ // So the lines that schedule and unschedule say what the schedule is to be
23
+ // rather than what to change: when the session runs one, it looks at what it
24
+ // has itself (CronList), removes every grooming job, and then makes the one
25
+ // asked for, or none. Run in any order, the last one typed is what holds
26
+ // (review of PR #322).
27
+ //
28
+ // Claude Code ends a recurring job a week after it was made, so the job's own
29
+ // prompt ends by renewing it. A renewal that fails either way shows here: no
30
+ // job means the fleet has quietly stopped being groomed, two mean it is groomed
31
+ // twice, and both are said plainly.
19
32
 
20
33
  import { existsSync, realpathSync } from 'node:fs';
21
34
  import path from 'node:path';
22
35
 
23
36
  import { botDir, readBot, requireBotsFolder } from './bot.js';
24
- import { readBook, takeLock } from './book.js';
25
- import { ownCli, shellWord } from './launch.js';
26
- import { orca } from './orca.js';
37
+ import { readBook } from './book.js';
38
+ import { claudeTranscript } from './conversations.js';
39
+ import { harnessOf, ownCli, shellWord } from './launch.js';
40
+ import { tabs, tabToTypeInto, typeIntoTab } from './orca.js';
27
41
  import { BOT_FATHER } from './up.js';
42
+ import { lines } from './usage.js';
28
43
 
29
- /** What the kit calls its own grooming automation. */
30
- const NAME = 'obk grooming';
44
+ /** Bot Father's session the grooming runs in. */
45
+ export const GROOMING = 'grooming';
31
46
 
32
47
  /** A time of day, as a person writes one. */
33
48
  const AT = /^([01]\d|2[0-3]):([0-5]\d)$/;
34
49
 
35
- /** What the automation's session is told to do when it wakes. */
36
- const prompt = (bots) =>
37
- 'Run the daily grooming for this fleet, with the obk-grooming skill. Fix this '
38
- + 'run\'s end now and read what has happened since the last run\'s end up to it, '
39
- + `counting with ${shellWord(ownCli())} usage --since <last end> --until <this end>, and write this `
40
- + 'end down for the next run. Work out what it cost with obk-finops, keep '
41
- + 'each bot\'s profile notes, and send one short report to Bot Father\'s '
42
- + `management session. The kit here is ${shellWord(ownCli())}: run it wherever a skill says obk. `
43
- + `The bots folder is ${bots}.`;
50
+ /** A daily cron as the kit writes one: minute, hour, every day. */
51
+ const DAILY = /^(\d{1,2}) (\d{1,2}) \* \* \*$/;
52
+
53
+ /** How long Claude Code keeps a recurring job after it was made (tech notes, section 2). */
54
+ const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
44
55
 
45
56
  /**
46
- * Say what the grooming is, and make it so when asked.
47
- * Returns `{ exists }`, and `{ exists, enabled, at, id }` once there is one.
57
+ * What the session looks for in its own CronList to find its grooming jobs.
58
+ * Only the start of the marker: CronList shows a prompt cut short, and the
59
+ * grooming session is one Bot Father's, so its jobs are all this fleet's.
60
+ */
61
+ const PREFIX = 'obk grooming';
62
+
63
+ /** How long Orca is given to say what is in the grooming tab. */
64
+ const LOOK_MS = 2000;
65
+
66
+ /**
67
+ * What every grooming job's prompt starts with, which is how the kit and the
68
+ * session tell it from a job of the user's own in the same conversation.
69
+ */
70
+ export const markerOf = (bots) => `obk grooming for ${bots}`;
71
+
72
+ /**
73
+ * Say what the grooming is, and ask the grooming session for a change when
74
+ * told to. `ask` is one of `on`, `move` (a new time for grooming that is on),
75
+ * `off`, `now` and `compact`, or undefined to only report; `at` is a time of
76
+ * day, `HH:MM`.
48
77
  *
49
- * Reading and setting are one command because they are one question: a caller
50
- * that has just moved the time wants to be told what it is now, and what it is
51
- * now is whatever Orca says rather than whatever the kit last asked for.
78
+ * Returns `{ session, jobs, asked }`: the grooming session as the book has it
79
+ * (or null), the grooming jobs alive in its conversation as the command found
80
+ * them, before anything it typed was done, and what it typed (or null).
52
81
  */
53
- export function grooming(bots, { at, on } = {}) {
82
+ export function grooming(bots, { at, ask } = {}) {
54
83
  requireBotsFolder(bots);
55
84
 
56
- // The folder as the file system knows it, the way `restart` and `message`
57
- // take it: Orca records an automation's workspace by its real path, so a
58
- // spelling that is not the canonical one finds nothing and offers to make a
59
- // second of what is already there.
85
+ // The folder as the file system knows it: Claude Code files a transcript
86
+ // under the real path, and Orca knows a tab's project by it.
60
87
  const home = realHome(botDir(bots, BOT_FATHER));
61
88
  if (!existsSync(path.join(home, 'bot.yaml'))) {
62
- throw new Error(`there is no ${BOT_FATHER} in ${bots}, and the grooming runs in its Orca project. Run obk init first.`);
89
+ throw new Error(`there is no ${BOT_FATHER} in ${bots}, and the grooming runs in a session of its. Run obk init first.`);
63
90
  }
64
91
  if (at !== undefined && !AT.test(at)) {
65
92
  throw new Error(`--at is a time of day as 24 hours, such as 04:00, and got: ${at}`);
66
93
  }
67
94
 
68
95
  const bot = readBot(home, BOT_FATHER);
69
- const book = readBook(home);
70
- if (book.orca.project === undefined) {
71
- throw new Error(`${BOT_FATHER} has no Orca project yet, so there is nothing for the grooming to run in. Run ${shellWord(ownCli())} up --bots ${shellWord(bots)} --bot ${BOT_FATHER} first.`);
96
+ const found = bot.sessions.find((one) => one.name === GROOMING);
97
+ const entry = readBook(home).sessions[GROOMING] ?? {};
98
+ const tab = typeof entry.tab === 'string' ? entry.tab : undefined;
99
+ const conversation = typeof entry.session === 'string' ? entry.session : null;
100
+
101
+ const session = found === undefined ? null : {
102
+ name: GROOMING,
103
+ harness: harnessOf(found, bot.harness),
104
+ up: tab !== undefined && tabs(home).some((one) => one.tabId === tab),
105
+ conversation,
106
+ };
107
+ const jobs = session?.harness === 'claude' && conversation !== null
108
+ ? groomingJobs(ofThisProcess(home, entry), markerOf(bots), Date.now())
109
+ : [];
110
+
111
+ const line = ask === undefined ? undefined : lineFor(ask, { bots, session, jobs, at });
112
+ if (line !== undefined) typeInto(bots, home, session, tab, line);
113
+
114
+ // A move is turning it on at another time, and is typed as one.
115
+ return { session, jobs, asked: line === undefined ? null : (ask === 'move' ? 'on' : ask) };
116
+ }
117
+
118
+ /**
119
+ * The line that asks the grooming session for `ask`, or undefined when there is
120
+ * nothing to ask. Everything that makes the ask impossible refuses here, before
121
+ * Orca is asked for anything.
122
+ */
123
+ function lineFor(ask, { bots, session, jobs, at }) {
124
+ // Nothing to turn off is an answer, unless a session is there to be told: an
125
+ // `--on` typed a moment ago may still be waiting in its tab.
126
+ if (ask === 'off' && jobs.length === 0 && !(session?.harness === 'claude' && session.up)) return undefined;
127
+ if (session === null) {
128
+ throw new Error(`${BOT_FATHER} has no ${GROOMING} session, so there is nothing to ask. Add it: ${addCommand(bots)}, then ${upCommand(bots)}.`);
129
+ }
130
+ if (session.harness !== 'claude') {
131
+ throw new Error(`${BOT_FATHER}'s ${GROOMING} session runs on ${session.harness}, and grooming runs on Claude Code's own schedule, so its session has to be a Claude Code one. Retire it and add it again: ${retireCommand(bots)}, then ${addCommand(bots)} --harness claude.`);
72
132
  }
73
133
 
74
- // Looking and making are one turn. Orca will not stop two runs creating two
75
- // automations for one fleet, and two of these means being groomed twice a day
76
- // for ever, so the lock the kit already uses for a bot's own file is held
77
- // across the pair (src/book.js).
78
- const lock = takeLock(home);
79
- try {
80
- return settle(bots, home, bot, { at, on });
81
- } finally {
82
- lock.release();
134
+ if (ask === 'off') return offLine(jobs);
135
+ if (ask === 'now') return nowLine(bots);
136
+ if (ask === 'compact') return '/compact';
137
+
138
+ if (ask === 'move' && jobs.length === 0) {
139
+ throw new Error(`grooming is off, so there is no job to move. Turning it on at that time is ${groomCommand(bots)} --on --at ${at}, once the user has said yes.`);
83
140
  }
84
- }
85
141
 
86
- /** The looking and the making, with the turn already taken. */
87
- function settle(bots, home, bot, { at, on }) {
88
- let mine = ours(home);
89
-
90
- if (mine === undefined && at !== undefined) {
91
- orca([
92
- 'automations', 'create',
93
- '--name', NAME,
94
- '--provider', bot.harness,
95
- '--trigger', 'daily',
96
- '--time', at,
97
- '--prompt', prompt(bots),
98
- '--workspace', `path:${home}`,
99
- '--workspace-mode', 'existing',
100
- // Off, whatever else was asked for: turning it on is a separate decision
101
- // and is made below, so that the one path into "it runs daily" is the
102
- // same whether it was made just now or a month ago.
103
- '--disabled',
104
- ]);
105
- mine = ours(home);
106
- } else if (mine !== undefined && at !== undefined && at !== timeOf(mine)) {
107
- // Orca refuses a time on its own, so the trigger goes with it every time.
108
- orca(['automations', 'edit', '--id', mine.id, '--trigger', 'daily', '--time', at]);
142
+ // On: at the time given, or at the one time every job there already has.
143
+ const times = [...new Set(jobs.map((job) => job.at))];
144
+ const when = at ?? (times.length === 1 ? times[0] : undefined);
145
+ if (when === undefined) {
146
+ throw new Error(jobs.length === 0
147
+ ? `grooming is off, so there is no time to keep. Say when: ${groomCommand(bots)} --on --at <HH:MM>.`
148
+ : `the grooming jobs there run at different times (${times.join(', ')}). Say which one to keep: ${groomCommand(bots)} --on --at <HH:MM>.`);
109
149
  }
150
+ return onLine(bots, cronOf(when), jobs);
151
+ }
110
152
 
111
- if (on !== undefined && mine !== undefined) {
112
- orca(['automations', 'edit', '--id', mine.id, on ? '--enabled' : '--disabled']);
153
+ /**
154
+ * Type `line` into the grooming tab, through the kit's one gate for typing into
155
+ * a running session: nothing goes into a tab with something on screen waiting
156
+ * for an answer, or with anything but Claude Code in front of it.
157
+ */
158
+ function typeInto(bots, home, session, tab, line) {
159
+ if (!session.up) {
160
+ throw new Error(`${BOT_FATHER}'s ${GROOMING} session is not up, so nothing was typed. Bring it up with ${upCommand(bots)}, then run this again.`);
113
161
  }
162
+ const found = tabToTypeInto(home, tab, LOOK_MS);
163
+ if (found.blocked !== undefined) {
164
+ throw new Error(`the ${GROOMING} tab is waiting for an answer (${found.blocked}), so nothing was typed. Answer it in the tab, then run this again.`);
165
+ }
166
+ if (found.unsure !== undefined) throw new Error(`the ${GROOMING} tab: ${found.unsure}.`);
167
+ if (found.handle === undefined || found.agent !== 'claude') {
168
+ throw new Error(`the ${GROOMING} tab has no Claude Code in front of it, so nothing was typed. Bring it back with ${restartCommand(bots)}, then run this again.`);
169
+ }
170
+ typeIntoTab(found.handle, line);
171
+ }
114
172
 
115
- // Asked again rather than assumed: the user can move it or switch it in
116
- // Orca's own interface, and what this prints has to be what is there.
117
- const now = ours(home);
118
- return now === undefined
119
- ? { exists: false }
120
- : { exists: true, enabled: now.enabled === true, at: timeOf(now), id: now.id };
173
+ /**
174
+ * The transcript lines of every conversation the grooming session's running
175
+ * Claude Code has had, oldest conversation first.
176
+ *
177
+ * A job lives in the process, not in one conversation: seen live, a `/clear`
178
+ * left the job made before it running, and the new conversation's CronList
179
+ * still answered it. So the conversation the book holds now is read, and before
180
+ * it each one it cleared, back through the book's history, until the one where
181
+ * this process began: the kit's own SessionStart hook leaves a line in every
182
+ * conversation it sees start, and `startup` or `resume` there means a process
183
+ * started with it (a fresh start, the kit's restart, or Orca restoring the tab).
184
+ * Whatever came before that belonged to a process that has gone.
185
+ */
186
+ function ofThisProcess(home, entry) {
187
+ const history = Array.isArray(entry.history) ? entry.history : [];
188
+ const read = [];
189
+ let id = entry.session;
190
+ for (let back = history.length - 1; ; back -= 1) {
191
+ const those = lines(claudeTranscript(home, id));
192
+ read.unshift(those);
193
+ if (those.some(beganHere)) break;
194
+ const was = history[back];
195
+ if (was === null || typeof was !== 'object' || was.ended !== 'clear' || typeof was.session !== 'string') break;
196
+ id = was.session;
197
+ }
198
+ return read.flat();
121
199
  }
122
200
 
201
+ /** Whether a transcript line is the kit's hook seeing a process start on this conversation. */
202
+ const beganHere = (line) => line.type === 'attachment'
203
+ && ['SessionStart:startup', 'SessionStart:resume'].includes(line.attachment?.hookName);
204
+
123
205
  /**
124
- * The kit's own grooming automation for this Bot Father, if there is one.
206
+ * The grooming jobs alive in transcript lines at `now`, oldest first:
207
+ * `{ id, at, cron, made, expires }`.
125
208
  *
126
- * Both halves are needed. The folder alone would adopt whatever the user set up
127
- * in the same project, and the name alone would collide across two bots folders
128
- * on one machine, which each have a Bot Father of their own.
209
+ * A job is made by a CronCreate that succeeded, recurring (Claude Code's
210
+ * default), with a prompt that starts with the marker. It ends at a CronDelete
211
+ * of its id that succeeded, or a week after it was made. A call is matched to
212
+ * its answer by the tool call's id; an answer that failed is a string, not the
213
+ * job, and makes or ends nothing.
129
214
  */
130
- function ours(home) {
131
- const listed = orca(['automations', 'list']).automations ?? [];
132
- const found = listed.filter((one) => one?.name === NAME && one?.runContext?.path === home);
133
-
134
- // More than one is not a thing to pick from. Whichever were chosen, the other
135
- // would go on running unseen, and this command is where the user would have
136
- // looked for it. So it says what it found and leaves both alone.
137
- if (found.length > 1) {
138
- throw new Error(`${home} has ${found.length} groomings rather than one: ${found.map((one) => one.id).join(', ')}. Remove the ones you do not want in Orca, then run this again.`);
215
+ export function groomingJobs(entries, marker, now) {
216
+ const calls = new Map();
217
+ const alive = new Map();
218
+ for (const entry of entries) {
219
+ const content = Array.isArray(entry.message?.content) ? entry.message.content : [];
220
+ for (const item of content) {
221
+ if (entry.type === 'assistant' && item?.type === 'tool_use') calls.set(item.id, item);
222
+ if (entry.type !== 'user' || item?.type !== 'tool_result' || item.is_error === true) continue;
223
+
224
+ const call = calls.get(item.tool_use_id);
225
+ const result = entry.toolUseResult;
226
+ if (call === undefined || result === null || typeof result !== 'object' || typeof result.id !== 'string') continue;
227
+ if (call.name === 'CronDelete') alive.delete(result.id);
228
+ if (call.name !== 'CronCreate') continue;
229
+
230
+ const { cron, prompt, recurring } = call.input ?? {};
231
+ const made = Date.parse(entry.timestamp ?? '');
232
+ if (recurring === false || typeof prompt !== 'string' || !prompt.startsWith(marker) || Number.isNaN(made)) continue;
233
+ alive.set(result.id, { id: result.id, at: timeOf(cron), cron, made, expires: made + WEEK_MS });
234
+ }
139
235
  }
140
- return found[0];
236
+ return [...alive.values()]
237
+ .filter((job) => job.expires > now)
238
+ .sort((left, right) => left.made - right.made)
239
+ .map((job) => ({ ...job, made: new Date(job.made).toISOString(), expires: new Date(job.expires).toISOString() }));
240
+ }
241
+
242
+ /** `HH:MM` as the daily cron Claude Code takes: `04:00` is `0 4 * * *`. */
243
+ const cronOf = (at) => {
244
+ const [, hour, minute] = AT.exec(at);
245
+ return `${Number(minute)} ${Number(hour)} * * *`;
246
+ };
247
+
248
+ /** A daily cron read back as `HH:MM`, or undefined for any other shape. */
249
+ function timeOf(cron) {
250
+ const found = DAILY.exec(String(cron ?? ''));
251
+ if (found === null) return undefined;
252
+ return `${found[2].padStart(2, '0')}:${found[1].padStart(2, '0')}`;
141
253
  }
142
254
 
255
+ /** The grooming run itself: what a job does each day, and what `--now` asks for once. */
256
+ const run = (bots) =>
257
+ `${markerOf(bots)}: run the daily grooming for this fleet, with the obk-grooming skill. Fix this `
258
+ + 'run\'s end now and read what has happened since the last run\'s end up to it, '
259
+ + `counting with ${shellWord(ownCli())} usage --since <last end> --until <this end>, and write this `
260
+ + 'end down for the next run. Work out what it cost with obk-finops, keep '
261
+ + 'each bot\'s profile notes, and send one short report to Bot Father\'s '
262
+ + `management session. The kit here is ${shellWord(ownCli())}: run it wherever a skill says obk. `
263
+ + `The bots folder is ${bots}.`;
264
+
265
+ /**
266
+ * A job's prompt: the run, and then its own renewal, since Claude Code ends a
267
+ * recurring job a week after it was made. The renewal copies the prompt it was
268
+ * fired with, so the job carries itself on; the one that fired is then the
269
+ * older of the two.
270
+ */
271
+ const jobPrompt = (bots, cron) =>
272
+ `${run(bots)} When the report is sent, renew this schedule, which Claude Code ends a week after it was made: `
273
+ + `call CronCreate with cron "${cron}", recurring true, and this whole prompt, word for word, as its prompt; `
274
+ + `then call CronList and CronDelete every other job there whose prompt starts with "${PREFIX}".`;
275
+
276
+ /**
277
+ * The part of a line that clears the grooming jobs the session has when it runs
278
+ * the line, which is not always what the kit saw when it typed it.
279
+ */
280
+ const clearing = (jobs) =>
281
+ `call CronList, and call CronDelete on every job there whose prompt starts with "${PREFIX}"`
282
+ + (jobs.length === 0 ? '' : ` (when this was typed they were ${jobs.map((job) => job.id).join(', ')})`);
283
+
284
+ const onLine = (bots, cron, jobs) =>
285
+ `obk groom asks you to schedule the daily grooming. First ${clearing(jobs)}. `
286
+ + `Then call CronCreate once, with cron "${cron}", recurring true, and as its prompt everything after "Prompt:" `
287
+ + 'at the end of this line, word for word. Do not run the grooming now; say in one line what you scheduled. '
288
+ + `Prompt: ${jobPrompt(bots, cron)}`;
289
+
290
+ const offLine = (jobs) =>
291
+ `obk groom asks you to turn the daily grooming off: ${clearing(jobs)}. `
292
+ + 'Schedule nothing in their place, and say in one line what you cancelled.';
293
+
294
+ const nowLine = (bots) => `obk groom asks you to run the daily grooming once, now, and to schedule nothing. ${run(bots)}`;
295
+
296
+ /** The command that adds the grooming session, for a caller to run. */
297
+ export const addCommand = (bots) =>
298
+ `${shellWord(ownCli())} session add --bots ${shellWord(bots)} --bot ${BOT_FATHER} --name ${GROOMING}`;
299
+
300
+ /** The command that brings Bot Father's sessions up. */
301
+ export const upCommand = (bots) => `${shellWord(ownCli())} up --bots ${shellWord(bots)} --bot ${BOT_FATHER}`;
302
+
303
+ /** This command again, for a caller to add its flags to. */
304
+ export const groomCommand = (bots) => `${shellWord(ownCli())} groom --bots ${shellWord(bots)}`;
305
+
306
+ const retireCommand = (bots) => `${shellWord(ownCli())} retire --bots ${shellWord(bots)} --bot ${BOT_FATHER} --session ${GROOMING}`;
307
+
308
+ const restartCommand = (bots) => `${shellWord(ownCli())} restart --bots ${shellWord(bots)} --bot ${BOT_FATHER} --session ${GROOMING}`;
309
+
143
310
  /** A bot home as the file system knows it, or as it was given when it is not there. */
144
311
  function realHome(home) {
145
312
  try {
@@ -148,12 +315,3 @@ function realHome(home) {
148
315
  return home;
149
316
  }
150
317
  }
151
-
152
- /** When an automation runs, read out of Orca's own recurrence rule. */
153
- function timeOf(automation) {
154
- const rule = String(automation?.rrule ?? '');
155
- const hour = /BYHOUR=(\d+)/.exec(rule)?.[1];
156
- const minute = /BYMINUTE=(\d+)/.exec(rule)?.[1];
157
- if (hour === undefined || minute === undefined) return undefined;
158
- return `${hour.padStart(2, '0')}:${minute.padStart(2, '0')}`;
159
- }
package/src/launch.js CHANGED
@@ -99,6 +99,48 @@ export const setsNetworkAccess = (session) =>
99
99
  export const reachesMail = (session, harness) =>
100
100
  harness !== 'codex' || !turnedOff(session);
101
101
 
102
+ /** Codex's flag for running without its shared background server (0.156.1 on). */
103
+ const NO_DAEMON = '--no-daemon';
104
+
105
+ // A string is shell text, so it is read as the shell will split it: `'--no-daemon'`
106
+ // is the flag, and `'/tmp/a --no-daemon b'` is one path that merely says it.
107
+ const setsNoDaemon = (session) => {
108
+ const extra = session.extra_args;
109
+ const words = Array.isArray(extra) ? extra.map(String) : set(extra) ? shellWords(String(extra)) : [];
110
+ return words.includes(NO_DAEMON);
111
+ };
112
+
113
+ /**
114
+ * The words a shell makes of `text`: split on blanks outside quotes, quotes
115
+ * removed, a backslash keeping the next character. Nothing is expanded.
116
+ */
117
+ function shellWords(text) {
118
+ const words = [];
119
+ let word;
120
+ let quote;
121
+ for (let at = 0; at < text.length; at += 1) {
122
+ const char = text[at];
123
+ if (quote === "'") {
124
+ if (char === "'") quote = undefined;
125
+ else word += char;
126
+ } else if (quote === '"') {
127
+ if (char === '"') quote = undefined;
128
+ else if (char === '\\' && '"\\$`'.includes(text[at + 1] ?? ' ')) word += text[(at += 1)];
129
+ else word += char;
130
+ } else if (/\s/.test(char)) {
131
+ if (word !== undefined) words.push(word);
132
+ word = undefined;
133
+ } else {
134
+ word ??= '';
135
+ if (char === "'" || char === '"') quote = char;
136
+ else if (char === '\\') word += text[(at += 1)] ?? '';
137
+ else word += char;
138
+ }
139
+ }
140
+ if (word !== undefined) words.push(word);
141
+ return words;
142
+ }
143
+
102
144
  const turnedOff = (session) =>
103
145
  extraWords(session.extra_args).some((word) => word.includes(`${NETWORK_ACCESS}=false`));
104
146
 
@@ -174,6 +216,11 @@ export function launchCommand(session, { harness, home, workDir, prompt, promptF
174
216
  // take every other flag exactly as a fresh session does.
175
217
  if (resume !== undefined && harness === 'codex') words.push('resume');
176
218
  words.push(...APPROVAL[harness][set(session.approval) ? session.approval : DEFAULT_APPROVAL]);
219
+ // Codex 0.157 shares one background server between sessions by default, and
220
+ // a resume through it was seen to fail; without it Codex runs as 0.156 did
221
+ // (tech notes, section 3, #330). Codex refuses the flag twice, so the user's
222
+ // own wins.
223
+ if (harness === 'codex' && !setsNoDaemon(session)) words.push(NO_DAEMON);
177
224
 
178
225
  if (harness === 'claude') {
179
226
  // The name is the address other Claude sessions write to, so it goes on
package/src/message.js CHANGED
@@ -350,13 +350,15 @@ const stamp = () => new Date().toISOString().replaceAll(':', '-').replace('.', '
350
350
  * into one of those is not a message: it is an answer to whatever question is
351
351
  * up. That is not a worry, it is a thing that happened — in slice 03 a second
352
352
  * line went into a tab on Claude Code's folder-trust list, confirmed its
353
- * default, `No, exit`, and the harness quit (tech notes, section 1). So where
354
- * Orca says a tab is blocked, nothing is typed and the mail waits.
353
+ * default, `No, exit`, and the harness quit (tech notes, section 1). And in
354
+ * #329 a return took Codex's "Update now". So where Orca says a tab is
355
+ * blocked, or the kit sees a numbered choice list of the harness's own on its
356
+ * screen, nothing is typed and the mail waits.
355
357
  *
356
- * Orca's `blockedReason` catches Codex's screens and not Claude Code's, which
357
- * answer as idle with nothing said, so this narrows the case rather than
358
- * closing it. What closes it is nobody sending to a session before its first
359
- * screens are answered, which is the caller's work either way.
358
+ * That narrows the case rather than closing it: a question drawn any other way,
359
+ * such as Claude Code's unnumbered trust list, is seen by neither. What closes
360
+ * it is nobody sending to a session before its first screens are answered,
361
+ * which is the caller's work either way.
360
362
  */
361
363
  function nudge(to, from, subject) {
362
364
  if (to.tab === undefined) return { nudged: false };
@@ -3,7 +3,7 @@
3
3
  //
4
4
  // It asks Orca's runtime, through Orca's own client, for `project.update` with
5
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
6
+ // projects again, and Orca's CLI does not offer it (#224, ADR 0023). It exits 0
7
7
  // only when the runtime took the call; anything else is a non-zero exit, and
8
8
  // the kit reads every non-zero exit the same way.
9
9
 
package/src/orca.js CHANGED
@@ -1,4 +1,4 @@
1
- // Every call the kit makes to Orca goes through here (ADR 0021). Orca's CLI
1
+ // Every call the kit makes to Orca goes through here (ADR 0023). Orca's CLI
2
2
  // changes often, so the kit reads `--json` and never the human text, and keeps
3
3
  // the parsing in one place.
4
4
  //
@@ -192,7 +192,7 @@ const CLIENT_KILL_MS = 3000;
192
192
  * `setup-update` and `setup-delete` do not say so. `project.update` with no
193
193
  * changes does, and Orca's CLI does not offer it, so this goes through Orca's
194
194
  * own runtime client out of the installed app, run by Orca's binary the way
195
- * its `bin/orca` runs its CLI (ADR 0021). None of that is Orca's published
195
+ * its `bin/orca` runs its CLI (ADR 0023). None of that is Orca's published
196
196
  * interface, so anything that goes wrong is a quiet false: it never throws and
197
197
  * is never tried twice, and the caller prints `RELOAD_LINE` either way.
198
198
  */
@@ -262,7 +262,9 @@ export const retitleTab = (handle, title) =>
262
262
  * undefined when that cannot be read, with `unreadable` saying why. `answered` (an `ok` from `tui-idle`) and
263
263
  * `agent` are Orca's own hints, for a caller that has nothing better.
264
264
  * `blockedReason` is Orca saying something on screen wants answering, which is
265
- * for the caller to deal with, not the kit.
265
+ * for the caller to deal with, not the kit. `question` is the kit's own look at
266
+ * the screen, as `questionIn` answers, and `screenUnreadable` says why there
267
+ * was none.
266
268
  */
267
269
  export function harnessInTab(handle, timeoutMs) {
268
270
  const args = ['terminal', 'wait', '--terminal', handle, '--for', 'tui-idle', '--timeout-ms', String(timeoutMs)];
@@ -281,9 +283,59 @@ export function harnessInTab(handle, timeoutMs) {
281
283
  blockedReason: answer.result?.wait?.blockedReason,
282
284
  agent,
283
285
  ...frontOf(shown?.ptyId),
286
+ ...screenOf(handle),
284
287
  };
285
288
  }
286
289
 
290
+ /** The kit's word for a tab whose screen shows a question of its harness's own (#329). */
291
+ export const QUESTION_ON_SCREEN = 'question-on-screen';
292
+
293
+ /**
294
+ * What the tab `handle` renders: `{ question }`, true when it shows a question
295
+ * of its harness's own, or `{ screenUnreadable: <why> }`. Orca refusing is an
296
+ * answer that cannot be read, not an error, and so is anything that is not the
297
+ * rendered screen: accumulated output comes back in fragments (tech notes,
298
+ * section 1).
299
+ */
300
+ function screenOf(handle) {
301
+ let read;
302
+ try {
303
+ read = orca(['terminal', 'read', '--terminal', handle, '--screen']).terminal;
304
+ } catch (error) {
305
+ return { screenUnreadable: `Orca would not read its screen: ${error.message}` };
306
+ }
307
+ if (read?.source !== 'screen' || !Array.isArray(read.tail)) {
308
+ return { screenUnreadable: `Orca gave no rendered screen for it (source: ${read?.source ?? 'none'})` };
309
+ }
310
+ return { question: questionIn(read.tail) };
311
+ }
312
+
313
+ /** A row the harness starts with its selection pointer: `›` on Codex, `❯` on Claude Code. */
314
+ const POINTER_ROW = /^ *[›❯]/;
315
+
316
+ /** That pointer on a numbered choice, and the column its number starts in. */
317
+ const ON_A_CHOICE = /^( *[›❯] +)\d+\. /;
318
+
319
+ /**
320
+ * Whether the rows of a rendered screen hold a question of the harness's own.
321
+ *
322
+ * Every one seen is a numbered list of choices with the harness's pointer on
323
+ * one (tech notes, section 1; ADR 0023). The same pointer starts the harness's
324
+ * input line and its echo of the user's past turns, and the input line is the
325
+ * lowest of them whenever it is on screen, so only the lowest pointer row is
326
+ * asked about: a question counts while it stands in the input line's place.
327
+ * Unnumbered lists do not count. Codex puts a status row right under its input
328
+ * line, lined up with it, and by its layout that is one.
329
+ */
330
+ export function questionIn(rows) {
331
+ const at = rows.findLastIndex((row) => POINTER_ROW.test(row));
332
+ const pointer = at < 0 ? null : ON_A_CHOICE.exec(rows[at]);
333
+ if (pointer === null) return false;
334
+ const column = pointer[1].length;
335
+ const choice = (row) => typeof row === 'string' && row.slice(0, column).trim() === '' && /^\d+\. /.test(row.slice(column));
336
+ return choice(rows[at - 1]) || choice(rows[at + 1]);
337
+ }
338
+
287
339
  /**
288
340
  * Whether the kit may type a line into `tabId`, the tab the book holds for a
289
341
  * session in the Orca project at `home`, and the handle to type into when it
@@ -323,6 +375,13 @@ export function tabToTypeInto(home, tabId, timeoutMs) {
323
375
  : `${seen.command} holds its terminal, and Orca names ${seen.agent ?? 'no agent'} in it`;
324
376
  return { unsure: `the kit could not tell whether a harness is running in it (${why}), so nothing was typed` };
325
377
  }
378
+ // Orca's reason does not cover every question: it called Codex's update
379
+ // offer idle, and a return typed into it took "Update now" (#329). So the
380
+ // screen is read too, and one that cannot be read is not a reason to type.
381
+ if (seen.question === true) return { blocked: QUESTION_ON_SCREEN };
382
+ if (seen.question === undefined) {
383
+ return { unsure: `the kit could not tell whether a question is waiting on its screen (${seen.screenUnreadable}), so nothing was typed` };
384
+ }
326
385
  return { handle: live.handle, agent: seen.agent };
327
386
  }
328
387
 
@@ -350,7 +409,7 @@ const psCli = () => process.env.OBK_PS || '/bin/ps';
350
409
  * leading the group in front, or `{ unreadable: <why> }`.
351
410
  *
352
411
  * Orca gives the pane's pid in `diagnostics memory` and nowhere else, and `ps`
353
- * gives that pid's terminal's foreground process group (ADR 0021).
412
+ * gives that pid's terminal's foreground process group (ADR 0023).
354
413
  * On macOS the pane is `login` with the shell as its child, so the shell is in
355
414
  * front when the group is the pane's own or that of a child of a `login` pane.
356
415
  * `diagnostics memory` is a diagnostics command and may change, so everything
package/src/up.js CHANGED
@@ -13,7 +13,7 @@ import { botDir, botNames, displayName, readBot } from './bot.js';
13
13
  import { conversationsIn, hasConversation, heldAsUserTurn, transcriptsIn } from './conversations.js';
14
14
  import { installHook } from './hooks.js';
15
15
  import { addressOf, harnessOf, isAddressOf, isShortPrompt, launchCommand, mailboxStep, reachesMail, sessionTrouble, startPrompt, workDirOf } from './launch.js';
16
- import { asFolderProject, coordinatorOf, findProject, harnessInTab, makeMailbox, makeProject, openTab, retitleTab, tabs, TERMINAL_ENV, TIMED_OUT, tellWindow, typeIntoTab, useMailbox } from './orca.js';
16
+ import { asFolderProject, coordinatorOf, findProject, harnessInTab, makeMailbox, makeProject, openTab, QUESTION_ON_SCREEN, retitleTab, tabs, TERMINAL_ENV, TIMED_OUT, tellWindow, typeIntoTab, useMailbox } from './orca.js';
17
17
  import { TAB_ENV } from './record.js';
18
18
  import { buildAgents, rulesStamp } from './rules.js';
19
19
  import { linkSkills } from './skills.js';
@@ -377,13 +377,13 @@ function launchFor(bots, bot, session, harness, home, workDir, resume, held) {
377
377
  // through the tab's shell a character at a time.
378
378
  const promptFile = prompt === undefined || isShortPrompt(prompt) ? undefined : promptPath(bots, bot.name, session.name);
379
379
  // A new conversation is given a new name. A resume goes on under the one the
380
- // kit gave it, and under no `-n` at all when the book `held` none of the
381
- // kit's own: a resume keeps whatever name the conversation has, which is
382
- // proven, and whether `-n` renames it is not (#286). Such a session is
383
- // written to through its mailbox until it next starts fresh.
380
+ // kit gave it, and gets a new one when the book `held` none of the kit's own:
381
+ // `--resume <id> -n <name>` renames the conversation, proven live (#319), so
382
+ // a session still under the shared `<bot>.<session>` moves to an address of
383
+ // its own here.
384
384
  const address = harness !== 'claude' ? undefined
385
- : resume === undefined ? addressOf(bot.name, session.name)
386
- : isAddressOf(bot.name, session.name, held) ? held : undefined;
385
+ : resume !== undefined && isAddressOf(bot.name, session.name, held) ? held
386
+ : addressOf(bot.name, session.name);
387
387
  const line = launchCommand(session, {
388
388
  harness,
389
389
  home,
@@ -440,7 +440,7 @@ async function heldInRecord(home, name, harness, prompt, { launched, running })
440
440
 
441
441
  /**
442
442
  * One look at a tab the kit just typed a launch line into: whether a harness
443
- * is running there, and what it is waiting on if Orca says.
443
+ * is running there, and what it is waiting on if Orca or its screen says.
444
444
  *
445
445
  * The harness is up when something other than the shell holds the tab's
446
446
  * terminal, busy or not, or when Orca sees a question on its screen. Orca's
@@ -452,7 +452,10 @@ function lookFor(handle, timeoutMs) {
452
452
  const seen = harnessInTab(handle, timeoutMs);
453
453
  const running = seen.blockedReason !== undefined
454
454
  || (seen.front === undefined ? seen.answered || seen.agent !== undefined : seen.front === 'program');
455
- return { running, blockedReason: seen.blockedReason };
455
+ // A question the kit sees on the screen of a harness that is running, where
456
+ // Orca named none (#329).
457
+ const question = running && seen.question === true ? QUESTION_ON_SCREEN : undefined;
458
+ return { running, blockedReason: seen.blockedReason ?? question };
456
459
  }
457
460
 
458
461
  /**