@assuredloop/orca-bot-kit 0.7.0 → 0.8.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 +25 -0
- package/package.json +1 -1
- package/skills/obk-grooming/NOTICE.md +21 -5
- package/skills/obk-grooming/SKILL.md +47 -11
- package/src/book.js +2 -4
- package/src/cli.js +91 -24
- package/src/conversations.js +7 -1
- package/src/groom.js +263 -105
- package/src/message.js +8 -6
- package/src/orca-window.cjs +1 -1
- package/src/orca.js +63 -4
- package/src/up.js +6 -3
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
|
package/package.json
CHANGED
|
@@ -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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
the
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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.
|
|
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
|
-
|
|
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>]
|
|
168
|
-
Say
|
|
169
|
-
and
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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,
|
|
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
|
-
|
|
662
|
-
|
|
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
|
-
|
|
665
|
-
|
|
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:
|
|
1027
|
-
*
|
|
1028
|
-
* says what is there and what the next step would be
|
|
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(
|
|
1031
|
-
|
|
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
|
-
|
|
1034
|
-
|
|
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
|
-
`${
|
|
1040
|
-
|
|
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
|
|
package/src/conversations.js
CHANGED
|
@@ -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(
|
|
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
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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
|
-
//
|
|
11
|
-
//
|
|
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
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
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
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
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
|
-
/**
|
|
30
|
-
const
|
|
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
|
-
/**
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
*
|
|
47
|
-
*
|
|
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
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
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,
|
|
82
|
+
export function grooming(bots, { at, ask } = {}) {
|
|
54
83
|
requireBotsFolder(bots);
|
|
55
84
|
|
|
56
|
-
// The folder as the file system knows it
|
|
57
|
-
//
|
|
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
|
|
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
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
'
|
|
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
|
-
|
|
112
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
206
|
+
* The grooming jobs alive in transcript lines at `now`, oldest first:
|
|
207
|
+
* `{ id, at, cron, made, expires }`.
|
|
125
208
|
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
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
|
|
131
|
-
const
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
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/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).
|
|
354
|
-
*
|
|
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
|
-
*
|
|
357
|
-
*
|
|
358
|
-
*
|
|
359
|
-
*
|
|
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 };
|
package/src/orca-window.cjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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';
|
|
@@ -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
|
-
|
|
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
|
/**
|