@assuredloop/orca-bot-kit 0.4.0 → 0.5.1
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 +18 -0
- package/package.json +1 -1
- package/skills/obk-bot-building/SKILL.md +4 -1
- package/src/book.js +13 -0
- package/src/cli.js +61 -12
- package/src/conversations.js +73 -5
- package/src/health.js +124 -23
- package/src/message.js +28 -6
- package/src/orca.js +24 -3
- package/src/record.js +8 -1
- package/src/rules.js +15 -0
- package/src/settings.js +158 -0
- package/src/up.js +119 -39
- package/src/usage.js +147 -52
package/README.md
CHANGED
|
@@ -350,6 +350,11 @@ conversation it was having, rather than starting a new one. Clear a session —
|
|
|
350
350
|
keeps the old one, and gives the session its start prompt again, because that
|
|
351
351
|
prompt is what tells one session's duty from another's.
|
|
352
352
|
|
|
353
|
+
A session paused before its first turn can have an id in the book and no
|
|
354
|
+
conversation behind it: the harness has nothing on record for that id. There is
|
|
355
|
+
nothing to resume, so it starts again with its duty, and the book moves the id
|
|
356
|
+
into its history as `no conversation`.
|
|
357
|
+
|
|
353
358
|
One tab holds one session: the harness the kit started in it. Anything that
|
|
354
359
|
session runs inside the tab — a `codex exec`, a helper, a subagent's own process
|
|
355
360
|
— is not the session, and never becomes the conversation the kit brings back.
|
|
@@ -443,10 +448,23 @@ What it looks for:
|
|
|
443
448
|
go, and a link with nothing at the end of it;
|
|
444
449
|
- **a session the book knows that Orca does not**: its tab was closed, or the
|
|
445
450
|
machine was restarted, and `obk up` brings it back;
|
|
451
|
+
- **a session whose tab is open and whose harness is gone**: only the tab's
|
|
452
|
+
shell is in front, because the harness quit or crashed. `obk up` leaves an
|
|
453
|
+
open tab alone, so the finding names the `obk restart … --session` that
|
|
454
|
+
brings it back; when the book names no conversation for it, restart would
|
|
455
|
+
refuse, and the finding says to close the tab in Orca and run
|
|
456
|
+
`obk up … --session`. A tab the kit cannot read is never called down;
|
|
446
457
|
- **leftovers no book owns**: an Orca project inside your bots folder with no
|
|
447
458
|
bot in it, a tab in a bot's project that the book does not name, a start
|
|
448
459
|
prompt written for a session that has gone, a clone of a skills source you no
|
|
449
460
|
longer list, and the conversations the kit found and would not assign;
|
|
461
|
+
- **a session running on something other than its bot asks for**: a model,
|
|
462
|
+
effort, context or approval level that the harness's own record of the
|
|
463
|
+
conversation says differs from `bot.yaml`, and a session that started before
|
|
464
|
+
its bot's `AGENTS.md` last changed, with what brings it up to date. Beside the
|
|
465
|
+
findings, each running session gets a line saying, for each setting, whether
|
|
466
|
+
it matches, differs, was not asked for, or cannot be told from the record;
|
|
467
|
+
cannot be told is never taken for a match;
|
|
450
468
|
- **Orca's own default launch arguments**. Orca adds these to the agents it
|
|
451
469
|
launches, relaunches and resumes, so when they carry a permission bypass every
|
|
452
470
|
session runs in that mode whatever the kit asked for. The kit never sets that
|
package/package.json
CHANGED
|
@@ -302,7 +302,10 @@ it started, so a change to a bot's `AGENTS.md` may not reach one that is
|
|
|
302
302
|
already up even when a skill change would, and a session's model, effort or
|
|
303
303
|
approval only changes when it is started again (`obk restart`, on the user's
|
|
304
304
|
word, and then read its tabs as above). Say which you changed, and what that
|
|
305
|
-
means for the sessions running now.
|
|
305
|
+
means for the sessions running now. `obk health` names each running session
|
|
306
|
+
still on the older rules or on other settings, and what brings it up to date: on
|
|
307
|
+
Claude Code a `/clear` reads the rules again, and a start does on both harnesses.
|
|
308
|
+
Where everything needs restarting, remind
|
|
306
309
|
them to do it from Bot Father's ops tab.
|
|
307
310
|
|
|
308
311
|
And tell them. A bot's sessions share its rules and its skills, so a change
|
package/src/book.js
CHANGED
|
@@ -25,6 +25,9 @@ const HEADER = `# What Orca calls this bot on this machine, and where each of it
|
|
|
25
25
|
# and kept, because an address that is a tab dies with the tab. \`address\` is what
|
|
26
26
|
# a Claude session is called, which is what another Claude session writes to.
|
|
27
27
|
#
|
|
28
|
+
# \`rules\` is a stamp of the bot's AGENTS.md as the session last read it, so a
|
|
29
|
+
# change to it after the session started can be told apart.
|
|
30
|
+
#
|
|
28
31
|
# \`retired\` holds what the book knew about each session \`obk retire\` took off
|
|
29
32
|
# the bot, with when, so the conversations it had are still accounted for.
|
|
30
33
|
`;
|
|
@@ -266,3 +269,13 @@ export function rememberSession(entry = {}, id, ended) {
|
|
|
266
269
|
const was = { session: entry.session, ended: ended ?? 'replaced', at: new Date().toISOString() };
|
|
267
270
|
return { ...entry, session: id, history: [...(entry.history ?? []), was] };
|
|
268
271
|
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* The session entry once the id it held has turned out to have no conversation
|
|
275
|
+
* behind it: the id goes into the history with that reason, and the entry holds
|
|
276
|
+
* none until the harness names the new one.
|
|
277
|
+
*/
|
|
278
|
+
export function forgetSession(entry, ended) {
|
|
279
|
+
const { session, ...rest } = entry;
|
|
280
|
+
return { ...rest, history: [...(entry.history ?? []), { session, ended, at: new Date().toISOString() }] };
|
|
281
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -451,11 +451,12 @@ const commands = {
|
|
|
451
451
|
|
|
452
452
|
health(bots, values) {
|
|
453
453
|
refuseWhenOrcaIsDown();
|
|
454
|
-
const found = checkHealth(bots, { bot: values.bot });
|
|
454
|
+
const { found, sessions } = checkHealth(bots, { bot: values.bot });
|
|
455
455
|
return {
|
|
456
|
-
answer: { bots, found },
|
|
456
|
+
answer: { bots, found, sessions },
|
|
457
457
|
lines: [
|
|
458
458
|
...foundLines(found),
|
|
459
|
+
...sessions.map(settingsLine),
|
|
459
460
|
found.length === 0
|
|
460
461
|
? `Nothing to report: everything the kit keeps is where it should be. Your bots folder: ${bots}`
|
|
461
462
|
: `${found.length} thing${found.length === 1 ? '' : 's'} to look at above. What to do about each is yours to decide. Your bots folder: ${bots}`,
|
|
@@ -891,9 +892,14 @@ function refuseWhenOrcaIsDown() {
|
|
|
891
892
|
|
|
892
893
|
/** The same facts as `--json`, as lines, for a person reading along. */
|
|
893
894
|
/** The one line that says which conversation this tab was given, and from where. */
|
|
894
|
-
const
|
|
895
|
-
|
|
896
|
-
|
|
895
|
+
const howLines = (tab) => {
|
|
896
|
+
if (tab.resumed === true) return [' it was told to resume the session the book holds, with its conversation.'];
|
|
897
|
+
if (tab.noConversation === undefined) return [' it was told to start a new session: the book holds none for this one yet.'];
|
|
898
|
+
return [
|
|
899
|
+
` it was told to start a new session: the book's conversation ${tab.noConversation} had nothing on record`,
|
|
900
|
+
" behind it, so it went into the session's history.",
|
|
901
|
+
];
|
|
902
|
+
};
|
|
897
903
|
|
|
898
904
|
/**
|
|
899
905
|
* What the harness still has in this bot's folder that no session claims. The
|
|
@@ -956,12 +962,15 @@ function usageLines(usage, bots) {
|
|
|
956
962
|
lines.push(`${'bot'.padEnd(9)} ${entry.bot}`);
|
|
957
963
|
for (const session of entry.sessions) {
|
|
958
964
|
lines.push(`${'session'.padEnd(9)} ${session.name}`);
|
|
965
|
+
const notCounted = notCountedLine(session.not_counted);
|
|
959
966
|
lines.push(...session.conversations.map(conversationLine));
|
|
960
|
-
if (session.conversations.length === 0) lines.push(' nothing on record');
|
|
967
|
+
if (session.conversations.length === 0 && notCounted.length === 0) lines.push(' nothing on record');
|
|
968
|
+
lines.push(...notCounted);
|
|
961
969
|
}
|
|
962
|
-
if (entry.unclaimed.length > 0) {
|
|
970
|
+
if (entry.unclaimed.length > 0 || notCountedLine(entry.unclaimed_not_counted).length > 0) {
|
|
963
971
|
lines.push(`${'unclaimed'.padEnd(9)} ${entry.bot}: no session of this bot claims these`);
|
|
964
972
|
lines.push(...entry.unclaimed.map(conversationLine));
|
|
973
|
+
lines.push(...notCountedLine(entry.unclaimed_not_counted));
|
|
965
974
|
}
|
|
966
975
|
}
|
|
967
976
|
|
|
@@ -971,6 +980,19 @@ function usageLines(usage, bots) {
|
|
|
971
980
|
return lines;
|
|
972
981
|
}
|
|
973
982
|
|
|
983
|
+
/** What could not be counted, as one line, or no line when everything was. */
|
|
984
|
+
function notCountedLine(gaps) {
|
|
985
|
+
const said = [
|
|
986
|
+
[gaps.unreadable_transcripts, 'transcript', 'transcripts', 'unreadable'],
|
|
987
|
+
[gaps.broken_lines, 'line', 'lines', 'broken'],
|
|
988
|
+
[gaps.records_without_numbers, 'record', 'records', 'with no usable number'],
|
|
989
|
+
[gaps.records_without_time, 'record', 'records', 'with no time'],
|
|
990
|
+
]
|
|
991
|
+
.filter(([count]) => count > 0)
|
|
992
|
+
.map(([count, one, many, why]) => `${count} ${count === 1 ? one : many} ${why}`);
|
|
993
|
+
return said.length === 0 ? [] : [` not counted: ${said.join(', ')}`];
|
|
994
|
+
}
|
|
995
|
+
|
|
974
996
|
/** One conversation: what it is, what it ran as, and what it used. */
|
|
975
997
|
function conversationLine(one) {
|
|
976
998
|
const ran = [...one.models, ...one.efforts].join(' ');
|
|
@@ -1128,6 +1150,25 @@ function toldLine(bot, { session, harness, state, read, blocked, trouble }) {
|
|
|
1128
1150
|
const foundLines = (found) =>
|
|
1129
1151
|
found.flatMap((one) => [`${one.kind.padEnd(9)} ${one.where}`, ` ${one.says}`]);
|
|
1130
1152
|
|
|
1153
|
+
/**
|
|
1154
|
+
* What one running session runs on, in a line: each setting's state, with what
|
|
1155
|
+
* was asked for and what the harness recorded, and its rules.
|
|
1156
|
+
*/
|
|
1157
|
+
function settingsLine({ bot, session, running, settings, rules }) {
|
|
1158
|
+
const each = Object.entries(settings).map(([name, one]) => {
|
|
1159
|
+
const values = [
|
|
1160
|
+
...(one.configured === undefined ? [] : [`asked ${one.configured}`]),
|
|
1161
|
+
...(one.observed === undefined ? [] : [`runs ${one.observed}`]),
|
|
1162
|
+
];
|
|
1163
|
+
return `${name} ${one.state}${values.length === 0 ? '' : ` (${values.join(', ')})`}`;
|
|
1164
|
+
});
|
|
1165
|
+
// A session whose harness the kit cannot see in its tab is not called running.
|
|
1166
|
+
const [word, who] = running === 'yes'
|
|
1167
|
+
? ['running', `${bot} ${session}`]
|
|
1168
|
+
: ['unsure', `${bot} ${session} cannot tell whether its harness is running:`];
|
|
1169
|
+
return `${word.padEnd(9)} ${who} ${each.join(' ')} rules ${rules.state}`;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1131
1172
|
function tabLines({ bots, created, completed, rules, skills, tabs, paused = [], projects = [], found = [] }, summary) {
|
|
1132
1173
|
const lines = [
|
|
1133
1174
|
...created.map((entry) => `created ${entry}`),
|
|
@@ -1169,7 +1210,7 @@ function harnessLines(tab, bots) {
|
|
|
1169
1210
|
// What the line that was typed in asked for, and where the kit got it: the
|
|
1170
1211
|
// session the book holds, one the harness itself still had on record, or a new
|
|
1171
1212
|
// one — and if a new one, whether anything was known about an older one.
|
|
1172
|
-
const how =
|
|
1213
|
+
const how = howLines(tab);
|
|
1173
1214
|
|
|
1174
1215
|
if (!tab.harnessStarted) {
|
|
1175
1216
|
return [
|
|
@@ -1177,6 +1218,7 @@ function harnessLines(tab, bots) {
|
|
|
1177
1218
|
' the harness was typed in, and no session came up in the tab.',
|
|
1178
1219
|
` Look at it: ${lookAt(tab.terminal)}`,
|
|
1179
1220
|
...ANSWER_IT,
|
|
1221
|
+
...promptLines(tab),
|
|
1180
1222
|
];
|
|
1181
1223
|
}
|
|
1182
1224
|
|
|
@@ -1198,14 +1240,21 @@ function harnessLines(tab, bots) {
|
|
|
1198
1240
|
...ANSWER_IT,
|
|
1199
1241
|
];
|
|
1200
1242
|
|
|
1201
|
-
|
|
1202
|
-
if (tab.promptSent === false) {
|
|
1203
|
-
lines.push(' the start prompt was not typed in: the tab was not ready for it.');
|
|
1204
|
-
}
|
|
1243
|
+
lines.push(...promptLines(tab));
|
|
1205
1244
|
if (tab.unclaimed !== undefined) lines.push(...unclaimedLines(tab, bots));
|
|
1206
1245
|
return lines;
|
|
1207
1246
|
}
|
|
1208
1247
|
|
|
1248
|
+
/**
|
|
1249
|
+
* Whether the session got its start prompt, as its own record has it (#274):
|
|
1250
|
+
* received, or not confirmed yet. Nothing for a session that was told nothing.
|
|
1251
|
+
*/
|
|
1252
|
+
function promptLines(tab) {
|
|
1253
|
+
if (tab.promptReceived === true) return [' the start prompt was received: the session\'s own record holds it.'];
|
|
1254
|
+
if (tab.promptReceived === false) return [' the start prompt is not confirmed: the session\'s own record does not hold it yet.'];
|
|
1255
|
+
return [];
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1209
1258
|
try {
|
|
1210
1259
|
process.exitCode = await run(process.argv.slice(2));
|
|
1211
1260
|
} catch (error) {
|
package/src/conversations.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// directory, and every session starts at its bot home. Read only, never
|
|
11
11
|
// written: these are the harness's files (tech notes, sections 2 and 3).
|
|
12
12
|
|
|
13
|
-
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
13
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
14
14
|
import { homedir } from 'node:os';
|
|
15
15
|
import path from 'node:path';
|
|
16
16
|
|
|
@@ -32,12 +32,31 @@ const codexDir = () => path.join(homedir(), '.codex', 'sessions');
|
|
|
32
32
|
* then that it does not know, which is the honest one.
|
|
33
33
|
*/
|
|
34
34
|
export function conversationsIn(harness, home, since) {
|
|
35
|
-
|
|
35
|
+
// A subagent's conversation ran in the folder too, but no session had it: the
|
|
36
|
+
// harness says it is a helper's, so it is never offered to one to claim.
|
|
37
|
+
return transcriptsIn(harness, home, since)
|
|
38
|
+
.filter((one) => !one.subagent)
|
|
39
|
+
.map((one) => ({ id: one.id, at: one.at }));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Whether the harness has a record of the conversation `id` for this bot home,
|
|
44
|
+
* which is what its own resume looks for. A session paused before its first
|
|
45
|
+
* turn has an id the hook reported and nothing written behind it, and Claude
|
|
46
|
+
* Code answers a resume of it with "No conversation found" (#295).
|
|
47
|
+
*
|
|
48
|
+
* Asked of the file names alone, which both harnesses make from the id: nothing
|
|
49
|
+
* is opened, however many conversations the machine has.
|
|
50
|
+
*/
|
|
51
|
+
export function hasConversation(harness, home, id) {
|
|
52
|
+
if (harness === 'claude') return existsSync(path.join(claudeDir(home), `${id}.jsonl`));
|
|
53
|
+
return rollouts(codexDir()).some((file) => path.basename(file).endsWith(`-${id}.jsonl`));
|
|
36
54
|
}
|
|
37
55
|
|
|
38
56
|
/**
|
|
39
57
|
* The same conversations, each with the file the harness keeps it in, for a
|
|
40
|
-
* caller that has to read what is inside one rather than only know it is there
|
|
58
|
+
* caller that has to read what is inside one rather than only know it is there,
|
|
59
|
+
* and whether the harness marks it as a subagent's.
|
|
41
60
|
*/
|
|
42
61
|
export function transcriptsIn(harness, home, since) {
|
|
43
62
|
const from = since === undefined ? 0 : Date.parse(since);
|
|
@@ -45,7 +64,47 @@ export function transcriptsIn(harness, home, since) {
|
|
|
45
64
|
return found
|
|
46
65
|
.filter((one) => Number.isNaN(from) || one.at >= from)
|
|
47
66
|
.sort((left, right) => left.at - right.at)
|
|
48
|
-
.map((one) => ({ id: one.id, at: new Date(one.at).toISOString(), file: one.file }));
|
|
67
|
+
.map((one) => ({ id: one.id, at: new Date(one.at).toISOString(), file: one.file, subagent: one.subagent === true }));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Whether a conversation's own record holds `text` as a turn of the user's:
|
|
72
|
+
* what the harness itself wrote down as said to it, not a screen that was up.
|
|
73
|
+
*
|
|
74
|
+
* Only the user's turns count. Claude Code writes them as `user` lines, and its
|
|
75
|
+
* own meta lines and tool results in that same shape are not the user's. Codex
|
|
76
|
+
* writes each as a `user` message item; its `user_message` event is missing
|
|
77
|
+
* from many rollouts, and the item is in every one (tech notes, section 3).
|
|
78
|
+
* Its AGENTS.md goes in as a `user` item too, and cannot be the text asked about.
|
|
79
|
+
*/
|
|
80
|
+
export function heldAsUserTurn(harness, file, text) {
|
|
81
|
+
let lines;
|
|
82
|
+
try {
|
|
83
|
+
lines = readFileSync(file, 'utf8').split('\n');
|
|
84
|
+
} catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
const wanted = text.trim();
|
|
88
|
+
return lines.some((line) => userTexts(harness, line).some((said) => said.trim() === wanted));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The texts of one record line, when it is a turn of the user's. */
|
|
92
|
+
function userTexts(harness, line) {
|
|
93
|
+
let parsed;
|
|
94
|
+
try {
|
|
95
|
+
parsed = JSON.parse(line);
|
|
96
|
+
} catch {
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
if (harness === 'claude') {
|
|
100
|
+
if (parsed?.type !== 'user' || parsed.isMeta === true) return [];
|
|
101
|
+
const content = parsed.message?.content;
|
|
102
|
+
if (typeof content === 'string') return [content];
|
|
103
|
+
return Array.isArray(content) ? content.filter((block) => block?.type === 'text').map((block) => String(block.text)) : [];
|
|
104
|
+
}
|
|
105
|
+
const item = parsed?.type === 'response_item' ? parsed.payload : undefined;
|
|
106
|
+
if (item?.type !== 'message' || item.role !== 'user' || !Array.isArray(item.content)) return [];
|
|
107
|
+
return item.content.filter((block) => block?.type === 'input_text').map((block) => String(block.text));
|
|
49
108
|
}
|
|
50
109
|
|
|
51
110
|
function claudeConversations(home) {
|
|
@@ -87,7 +146,7 @@ function codexConversations(home, from) {
|
|
|
87
146
|
.flatMap((file) => {
|
|
88
147
|
const meta = sessionMeta(file);
|
|
89
148
|
return meta?.cwd === home && typeof meta.id === 'string'
|
|
90
|
-
? [{ id: meta.id, at: Date.parse(meta.timestamp ?? '') || startedAt(file) || 0, file }]
|
|
149
|
+
? [{ id: meta.id, at: Date.parse(meta.timestamp ?? '') || startedAt(file) || 0, file, subagent: isSubagent(meta) }]
|
|
91
150
|
: [];
|
|
92
151
|
});
|
|
93
152
|
}
|
|
@@ -113,6 +172,15 @@ function sessionMeta(file) {
|
|
|
113
172
|
}
|
|
114
173
|
}
|
|
115
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Codex marks a conversation it ran as a subagent under `source`: its own
|
|
177
|
+
* auto-review is `{ subagent: { other: 'guardian' } }`, and one a session spawned
|
|
178
|
+
* is `{ subagent: { thread_spawn: … } }`. A session's own is a plain word, such as
|
|
179
|
+
* `cli` or `exec` (tech notes, section 3).
|
|
180
|
+
*/
|
|
181
|
+
const isSubagent = (meta) =>
|
|
182
|
+
meta.source !== null && typeof meta.source === 'object' && 'subagent' in meta.source;
|
|
183
|
+
|
|
116
184
|
/** When a file was last written, for leaving out what is plainly too old. */
|
|
117
185
|
function lastTouched(file) {
|
|
118
186
|
try {
|
package/src/health.js
CHANGED
|
@@ -21,13 +21,14 @@
|
|
|
21
21
|
import { existsSync, readdirSync, realpathSync } from 'node:fs';
|
|
22
22
|
import path from 'node:path';
|
|
23
23
|
|
|
24
|
-
import { bookFile, readBook, tabIdsIn } from './book.js';
|
|
24
|
+
import { bookFile, readBook, sessionIdsIn, tabIdsIn } from './book.js';
|
|
25
25
|
import { botDir, botNames, botsDir, readBot } from './bot.js';
|
|
26
26
|
import { transcriptsIn } from './conversations.js';
|
|
27
27
|
import { hookTrouble } from './hooks.js';
|
|
28
|
-
import { bypassFlags, harnessOf, HARNESSES, sessionTrouble } from './launch.js';
|
|
29
|
-
import { orcaDefaultArgs, projects, tabs } from './orca.js';
|
|
30
|
-
import { agentsTrouble } from './rules.js';
|
|
28
|
+
import { bypassFlags, harnessOf, HARNESSES, ownCli, sessionTrouble, shellWord } from './launch.js';
|
|
29
|
+
import { frontOfTab, orcaDefaultArgs, projects, tabs } from './orca.js';
|
|
30
|
+
import { agentsTrouble, rulesStamp } from './rules.js';
|
|
31
|
+
import { settingsInUse } from './settings.js';
|
|
31
32
|
import { skillsTrouble } from './skills.js';
|
|
32
33
|
import { readSources, sourcesDir } from './sources.js';
|
|
33
34
|
import { BOT_FATHER, botsNamed, promptPath } from './up.js';
|
|
@@ -37,14 +38,18 @@ import { BOT_FATHER, botsNamed, promptPath } from './up.js';
|
|
|
37
38
|
* settings do to every session, what is lying about that no book owns, and then
|
|
38
39
|
* each bot in name order. `bot` narrows the per-bot checks to one; what the
|
|
39
40
|
* whole fleet lives under is reported whichever bot was named.
|
|
41
|
+
*
|
|
42
|
+
* Beside the list, `sessions`: each running session the book knows, with what
|
|
43
|
+
* it runs on against what its bot asks for now, good news included.
|
|
40
44
|
*/
|
|
41
45
|
export function checkHealth(bots, { bot: onlyBot } = {}) {
|
|
42
46
|
const names = botsNamed(bots, onlyBot);
|
|
43
47
|
const setups = projects();
|
|
44
48
|
|
|
45
49
|
const found = [...orcaSettingFindings(), ...leftInOrca(bots, setups), ...leftBeside(bots)];
|
|
46
|
-
|
|
47
|
-
|
|
50
|
+
const sessions = [];
|
|
51
|
+
for (const name of names) found.push(...aboutBot(bots, name, setups, sessions));
|
|
52
|
+
return { found, sessions };
|
|
48
53
|
}
|
|
49
54
|
|
|
50
55
|
/** One finding, as the report and the `--json` answer carry it. */
|
|
@@ -165,8 +170,8 @@ function leftBeside(bots) {
|
|
|
165
170
|
return found;
|
|
166
171
|
}
|
|
167
172
|
|
|
168
|
-
/** Everything wrong with one bot. */
|
|
169
|
-
function aboutBot(bots, name, setups) {
|
|
173
|
+
/** Everything wrong with one bot. Its running sessions are added to `sessions`. */
|
|
174
|
+
function aboutBot(bots, name, setups, sessions) {
|
|
170
175
|
const home = botDir(bots, name);
|
|
171
176
|
|
|
172
177
|
let bot;
|
|
@@ -185,7 +190,7 @@ function aboutBot(bots, name, setups) {
|
|
|
185
190
|
...agentsTrouble(bots, home, bot).map(said('config')),
|
|
186
191
|
...hooksOf(bots, home, bot).map(said('config')),
|
|
187
192
|
...skillsTrouble(bots, home, bot).map(said('skill')),
|
|
188
|
-
...inOrca(home, bot, setups),
|
|
193
|
+
...inOrca(bots, home, bot, setups, sessions),
|
|
189
194
|
];
|
|
190
195
|
}
|
|
191
196
|
|
|
@@ -206,12 +211,97 @@ const hooksOf = (bots, home, bot) => [...new Set(bot.sessions.map((session) => h
|
|
|
206
211
|
.map((harness) => hookTrouble(home, harness, { bots, bot: bot.name }))
|
|
207
212
|
.filter((trouble) => trouble !== undefined);
|
|
208
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Each session of this bot running in a tab Orca has, with what it runs on set
|
|
216
|
+
* beside what the bot asks for now, added to `sessions`; a finding for each
|
|
217
|
+
* one that runs on something else (#271, #272); and a finding for each one
|
|
218
|
+
* whose tab is open with its harness gone from it (#300).
|
|
219
|
+
*
|
|
220
|
+
* Two questions, each answered from where the answer is written. The settings
|
|
221
|
+
* from the harness's own record of the conversation the session is in now,
|
|
222
|
+
* which is what the harness really used. The rules from the stamp the kit noted
|
|
223
|
+
* in the book when the session last read them, against `AGENTS.md` now: nothing
|
|
224
|
+
* the harness writes says which instructions it read.
|
|
225
|
+
*
|
|
226
|
+
* Only a mismatch and older rules are findings. What cannot be read is said as
|
|
227
|
+
* unknown in `sessions`, and never taken for agreement.
|
|
228
|
+
*/
|
|
229
|
+
function runningOn(bots, home, bot, book, handles, sessions) {
|
|
230
|
+
const real = realpathOf(home) ?? home;
|
|
231
|
+
const records = new Map();
|
|
232
|
+
const recordOf = (harness, id) => {
|
|
233
|
+
if (!HARNESSES.includes(harness) || id === null) return undefined;
|
|
234
|
+
if (!records.has(harness)) records.set(harness, new Map(transcriptsIn(harness, real).map((one) => [one.id, one.file])));
|
|
235
|
+
return records.get(harness).get(id);
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const stamp = rulesStamp(home);
|
|
239
|
+
const found = [];
|
|
240
|
+
for (const session of bot.sessions) {
|
|
241
|
+
const entry = book.sessions[session.name];
|
|
242
|
+
// Running is a tab Orca still has, with the session's harness in front of
|
|
243
|
+
// it. A session whose tab is gone is not running whatever its record says,
|
|
244
|
+
// and the missing tab is said already.
|
|
245
|
+
if (bot.paused === true || session.paused === true || entry === null || typeof entry !== 'object') continue;
|
|
246
|
+
const handle = handles.get(entry.tab);
|
|
247
|
+
if (handle === undefined) continue;
|
|
248
|
+
|
|
249
|
+
// A harness can quit to the tab's shell and leave the tab open (#232), so
|
|
250
|
+
// the tab alone is not the answer: who holds its terminal is, asked the way
|
|
251
|
+
// the kit asks before it types into a tab. The shell in front is a session
|
|
252
|
+
// that is not running, and a finding: `up` finds the tab open and types
|
|
253
|
+
// nothing into it, so only a restart brings it back (#300). Anything else
|
|
254
|
+
// that is not its own harness, or a front that cannot be read, leaves it
|
|
255
|
+
// unsaid, and nothing unsaid is a finding.
|
|
256
|
+
const restart = `${shellWord(ownCli())} restart --bots ${shellWord(bots)} --bot ${bot.name} --session ${session.name}`;
|
|
257
|
+
const front = frontOfTab(handle);
|
|
258
|
+
if (front.front === 'shell') {
|
|
259
|
+
// Restart refuses a live tab whose conversation the book cannot name, and
|
|
260
|
+
// there may be none to name: the harness can quit before it has one. Its
|
|
261
|
+
// harness is gone, so closing the tab ends nothing, and `up` then starts
|
|
262
|
+
// the session again as it does any closed tab with no conversation.
|
|
263
|
+
const up = `${shellWord(ownCli())} up --bots ${shellWord(bots)} --bot ${bot.name} --session ${session.name}`;
|
|
264
|
+
const back = typeof entry.session === 'string'
|
|
265
|
+
? `${restart} does`
|
|
266
|
+
: `nor does obk restart, which refuses to close a tab whose conversation the book does not name, and the book names none for it. Its harness has already quit, so close the tab in Orca yourself; then ${up} starts it again, with a new conversation and its duty. Any conversation it left in this bot's folder is noted in the book as unclaimed, for health to list`;
|
|
267
|
+
found.push(finding('session', entry.tab, `${bot.name}'s session ${session.name} is not running: its tab ${entry.tab} is open with only the tab's shell in front, so its harness quit or crashed. obk up finds the tab open and types nothing into it, so it does not bring the session back; ${back}.`, bot.name));
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
const harness = harnessOf(session, bot.harness);
|
|
271
|
+
const running = front.front === 'program' && front.command === harness ? 'yes' : 'unknown';
|
|
272
|
+
const conversation = typeof entry.session === 'string' ? entry.session : null;
|
|
273
|
+
const file = recordOf(harness, conversation);
|
|
274
|
+
const settings = settingsInUse(harness, session, file, typeof entry.launched === 'string' ? entry.launched : undefined);
|
|
275
|
+
const rules = { state: typeof entry.rules !== 'string' ? 'unknown' : entry.rules === stamp ? 'current' : 'older' };
|
|
276
|
+
sessions.push({ bot: bot.name, session: session.name, harness, running, conversation, settings, rules });
|
|
277
|
+
if (running !== 'yes') continue;
|
|
278
|
+
|
|
279
|
+
const off = Object.entries(settings).filter(([, one]) => one.state === 'mismatch');
|
|
280
|
+
if (off.length > 0) {
|
|
281
|
+
const parts = off.map(([name, one]) => `${name}: bot.yaml asks for ${one.configured}, and it runs on ${one.observed}`).join('; ');
|
|
282
|
+
found.push(finding('session', file, `${bot.name}'s session ${session.name} does not run on what ${path.join(home, 'bot.yaml')} asks for. ${parts}. That is what the harness's own record of its conversation ${conversation} says. A session takes these when it starts, so if bot.yaml changed after it started, ${restart} starts it on them; if not, something else set them, such as a default of the harness's own or a change made inside the session.`, bot.name));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (rules.state === 'older') {
|
|
286
|
+
const agents = path.join(home, 'AGENTS.md');
|
|
287
|
+
// A clear on Claude Code reads the instructions again, as a start does
|
|
288
|
+
// (tech notes, section 2); whether a new conversation on Codex does is
|
|
289
|
+
// not established, so a start is all that is offered there.
|
|
290
|
+
const how = harness === 'claude'
|
|
291
|
+
? `A Claude Code session reads it again at a /clear in its tab, or when it starts: ${restart}`
|
|
292
|
+
: `A Codex session reads it when it starts: ${restart}`;
|
|
293
|
+
found.push(finding('session', agents, `${bot.name}'s session ${session.name} is running on older rules: ${agents} has changed since the kit noted which version this session read. ${how}`, bot.name));
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return found;
|
|
297
|
+
}
|
|
298
|
+
|
|
209
299
|
/**
|
|
210
300
|
* What Orca has of this bot against what the book says it should have: a
|
|
211
301
|
* session whose tab is gone, a conversation nobody claims, and a tab in the
|
|
212
302
|
* bot's project that the book does not name.
|
|
213
303
|
*/
|
|
214
|
-
function inOrca(home, bot, setups) {
|
|
304
|
+
function inOrca(bots, home, bot, setups, sessions) {
|
|
215
305
|
// The book is read where it is read, and a book nothing can parse is a
|
|
216
306
|
// finding like any other: it says which tab each session is in and which
|
|
217
307
|
// conversation it is running, so without it nothing else here can be asked —
|
|
@@ -236,19 +326,26 @@ function inOrca(home, bot, setups) {
|
|
|
236
326
|
// what paused means rather than something lost.
|
|
237
327
|
const closed = new Set(bot.sessions.filter((session) => bot.paused === true || session.paused === true).map((session) => session.name));
|
|
238
328
|
|
|
329
|
+
// What the harness has on record in this bot's folder, read once: it is a walk
|
|
330
|
+
// through every conversation on the machine.
|
|
331
|
+
const onRecord = recordIn(real ?? home, bot);
|
|
332
|
+
// A conversation the harness marks as a subagent's was never a session's, so a
|
|
333
|
+
// note that holds one is not a loose end for it (#287). The note stays as it is.
|
|
334
|
+
const helpers = new Set(onRecord.filter((one) => one.subagent).map((one) => one.id));
|
|
335
|
+
|
|
239
336
|
const found = [];
|
|
240
337
|
for (const [name, entry] of Object.entries(book.sessions)) {
|
|
241
338
|
if (typeof entry?.tab === 'string' && !there.has(entry.tab) && !closed.has(name)) {
|
|
242
339
|
found.push(finding('session', entry.tab, `${bot.name}'s session ${name} is in the book with tab ${entry.tab}, and Orca has no tab of that id: the tab was closed, or the machine was restarted. The conversation is in the book, and obk up opens a tab and brings it back.`, bot.name));
|
|
243
340
|
}
|
|
244
341
|
|
|
245
|
-
const unclaimed = Array.isArray(entry?.unclaimed) ? entry.unclaimed : [];
|
|
342
|
+
const unclaimed = (Array.isArray(entry?.unclaimed) ? entry.unclaimed : []).filter((id) => !helpers.has(id));
|
|
246
343
|
if (unclaimed.length > 0) {
|
|
247
344
|
found.push(finding('leftover', bookFile(home), `The book notes conversations under ${bot.name}'s session ${name} that no session of this bot claims: ${unclaimed.join(', ')}. They ran in this bot's folder, and the kit will not say whose they were. To give one back, write it into ${bookFile(home)} under ${name} as session: <id> and run obk up again.`, bot.name));
|
|
248
345
|
}
|
|
249
346
|
}
|
|
250
347
|
|
|
251
|
-
found.push(...offTheBook(real ?? home, bot, book));
|
|
348
|
+
found.push(...offTheBook(real ?? home, bot, book, onRecord));
|
|
252
349
|
|
|
253
350
|
// A tab outside the book is somebody else's, and in Bot Father's project it
|
|
254
351
|
// is the ops tab: the one tab the kit deliberately keeps no record of, so it
|
|
@@ -260,6 +357,7 @@ function inOrca(home, bot, setups) {
|
|
|
260
357
|
}
|
|
261
358
|
}
|
|
262
359
|
|
|
360
|
+
found.push(...runningOn(bots, home, bot, book, new Map(live.map((tab) => [tab.tabId, tab.handle])), sessions));
|
|
263
361
|
return found;
|
|
264
362
|
}
|
|
265
363
|
|
|
@@ -269,27 +367,30 @@ function inOrca(home, bot, setups) {
|
|
|
269
367
|
* fails it fails quietly (ADR 0020), so this is where a stale book shows: the
|
|
270
368
|
* harness's own record set beside it (ADR 0012).
|
|
271
369
|
*/
|
|
272
|
-
function offTheBook(home, bot, book) {
|
|
273
|
-
|
|
370
|
+
function offTheBook(home, bot, book, onRecord) {
|
|
371
|
+
// A retired session's conversations are in the book too, under `retired`.
|
|
372
|
+
const named = sessionIdsIn(book);
|
|
274
373
|
for (const entry of Object.values(book.sessions)) {
|
|
275
|
-
if (typeof entry?.session === 'string') named.add(entry.session);
|
|
276
|
-
for (const was of Array.isArray(entry?.history) ? entry.history : []) named.add(was?.session);
|
|
277
374
|
for (const id of Array.isArray(entry?.unclaimed) ? entry.unclaimed : []) named.add(id);
|
|
278
375
|
}
|
|
279
376
|
|
|
280
|
-
//
|
|
281
|
-
const
|
|
282
|
-
if (harnesses.size === 0) harnesses.add(bot.harness);
|
|
283
|
-
const stray = [...harnesses]
|
|
284
|
-
.filter((harness) => HARNESSES.includes(harness))
|
|
285
|
-
.flatMap((harness) => transcriptsIn(harness, home))
|
|
286
|
-
.filter((one) => !named.has(one.id));
|
|
377
|
+
// A subagent's conversation is no session's, so the book has no reason to name it.
|
|
378
|
+
const stray = onRecord.filter((one) => !one.subagent && !named.has(one.id));
|
|
287
379
|
if (stray.length === 0) return [];
|
|
288
380
|
|
|
289
381
|
const listed = stray.map((one) => `${one.id} (begun ${one.at})`).join(', ');
|
|
290
382
|
return [finding('session', bookFile(home), `The harness has conversations on record in ${bot.name}'s folder that the book does not name: ${listed}. The kit's hook may have missed them: a clear it did not record, or a Codex hooks file trusted late. To give one to a session, write it into ${bookFile(home)} under that session as session: <id> and run obk up again.`, bot.name)];
|
|
291
383
|
}
|
|
292
384
|
|
|
385
|
+
/** Every conversation on record in the bot's folder, from every harness any of its sessions runs on, as `obk usage` reads them. */
|
|
386
|
+
function recordIn(home, bot) {
|
|
387
|
+
const harnesses = new Set(bot.sessions.map((session) => harnessOf(session, bot.harness)));
|
|
388
|
+
if (harnesses.size === 0) harnesses.add(bot.harness);
|
|
389
|
+
return [...harnesses]
|
|
390
|
+
.filter((harness) => HARNESSES.includes(harness))
|
|
391
|
+
.flatMap((harness) => transcriptsIn(harness, home));
|
|
392
|
+
}
|
|
393
|
+
|
|
293
394
|
/** What a directory holds, and nothing at all when there is no directory. */
|
|
294
395
|
function namesIn(dir) {
|
|
295
396
|
try {
|
package/src/message.js
CHANGED
|
@@ -176,8 +176,8 @@ export function sendMessage(bots, { to: target, from: sender, tab, subject, text
|
|
|
176
176
|
* Orca fences a Run to one reader, its coordinator. A session whose tab is live
|
|
177
177
|
* is bound to that tab and read as it; one whose tab is down is read as its
|
|
178
178
|
* Run's coordinator, and nothing is bound (issue #249). A read that
|
|
179
|
-
* is not a peek acknowledges
|
|
180
|
-
* check
|
|
179
|
+
* is not a peek acknowledges every batch waiting, one after another, so one
|
|
180
|
+
* check hands over all of it (issue #299).
|
|
181
181
|
*/
|
|
182
182
|
export function checkMail(bots, { bot: botName, session: sessionName, tab, peek = false }) {
|
|
183
183
|
const who = sessionName === undefined && botName === undefined
|
|
@@ -212,9 +212,16 @@ export function checkMail(bots, { bot: botName, session: sessionName, tab, peek
|
|
|
212
212
|
trouble: `${who.bot}/${who.session} is not up, and its mailbox is bound to no tab, so there is nothing to read it as. Its mail waits until its tab is back: \`up\` brings it back, or \`unpause\` when it is paused.`,
|
|
213
213
|
};
|
|
214
214
|
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
215
|
+
// Orca hands mail over a batch at a time and replays the oldest until it is
|
|
216
|
+
// acknowledged; the acknowledgement answers with the next batch. So a read
|
|
217
|
+
// takes every batch in turn, or newer mail waits behind an older one (#299).
|
|
218
|
+
const messages = [];
|
|
219
|
+
let found = readMailbox(who.mailbox, { peek, handle });
|
|
220
|
+
for (;;) {
|
|
221
|
+
messages.push(...(found.messages ?? []).map((message) => asMessage(bots, message)));
|
|
222
|
+
if (peek || !found.deliveryId || (found.messages ?? []).length === 0) break;
|
|
223
|
+
found = ackMailbox(who.mailbox, found.deliveryId, handle);
|
|
224
|
+
}
|
|
218
225
|
|
|
219
226
|
return { bots, bot: who.bot, session: who.session, mailbox: who.mailbox, read: !peek, messages };
|
|
220
227
|
}
|
|
@@ -335,7 +342,7 @@ function nudge(to, from, subject) {
|
|
|
335
342
|
if (to.tab === undefined) return { nudged: false };
|
|
336
343
|
|
|
337
344
|
try {
|
|
338
|
-
const found =
|
|
345
|
+
const found = lookAt(to);
|
|
339
346
|
if (found.blocked !== undefined) return { nudged: false, blocked: found.blocked };
|
|
340
347
|
// A line that lands in a shell is run there, with the sender's subject in it.
|
|
341
348
|
if (found.unsure !== undefined) return { nudged: false, nudgeTrouble: found.unsure };
|
|
@@ -356,6 +363,21 @@ function nudge(to, from, subject) {
|
|
|
356
363
|
}
|
|
357
364
|
}
|
|
358
365
|
|
|
366
|
+
/**
|
|
367
|
+
* The look before the nudge. Orca now and then refuses a handle it has just
|
|
368
|
+
* listed as stale, and a fresh listing hands out one that works (tech notes,
|
|
369
|
+
* section 1), so that refusal earns one more listing and one more look. Only
|
|
370
|
+
* one: a second refusal is reported as it is (#294).
|
|
371
|
+
*/
|
|
372
|
+
function lookAt(to) {
|
|
373
|
+
try {
|
|
374
|
+
return tabToTypeInto(to.home, to.tab, LOOK_MS);
|
|
375
|
+
} catch (error) {
|
|
376
|
+
if (error.code !== 'terminal_handle_stale') throw error;
|
|
377
|
+
return tabToTypeInto(to.home, to.tab, LOOK_MS);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
359
381
|
/** `<bot>/<session>`, or a bot on its own when it has only the one session. */
|
|
360
382
|
function splitTarget(target) {
|
|
361
383
|
const parts = target.split('/');
|