@assuredloop/orca-bot-kit 0.3.0 → 0.5.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 +29 -3
- package/SETUP.md +3 -2
- package/package.json +1 -1
- package/skills/obk-bot-building/SKILL.md +27 -12
- package/src/book.js +3 -0
- package/src/cli.js +110 -21
- package/src/conversations.js +58 -4
- package/src/health.js +124 -23
- package/src/message.js +25 -28
- package/src/orca-window.cjs +21 -0
- package/src/orca.js +117 -10
- package/src/record.js +8 -1
- package/src/retire.js +39 -4
- package/src/rules.js +15 -0
- package/src/settings.js +158 -0
- package/src/skills.js +107 -16
- package/src/up.js +65 -20
- package/src/usage.js +147 -52
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
|
@@ -21,7 +21,7 @@ import path from 'node:path';
|
|
|
21
21
|
import { readBook } from './book.js';
|
|
22
22
|
import { botDir, botNames, readBot } from './bot.js';
|
|
23
23
|
import { harnessOf, ownCli, reachesMail, shellWord } from './launch.js';
|
|
24
|
-
import { ackMailbox,
|
|
24
|
+
import { ackMailbox, coordinatorOf, postMessage, readMailbox, tabs, tabToTypeInto, typeIntoTab, useMailbox } from './orca.js';
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
27
|
* How much of a message travels as itself. Above this it is written to a file
|
|
@@ -173,7 +173,9 @@ export function sendMessage(bots, { to: target, from: sender, tab, subject, text
|
|
|
173
173
|
/**
|
|
174
174
|
* What is waiting for a session, and the reading of it.
|
|
175
175
|
*
|
|
176
|
-
* Orca fences a Run to one reader,
|
|
176
|
+
* Orca fences a Run to one reader, its coordinator. A session whose tab is live
|
|
177
|
+
* is bound to that tab and read as it; one whose tab is down is read as its
|
|
178
|
+
* Run's coordinator, and nothing is bound (issue #249). A read that
|
|
177
179
|
* is not a peek acknowledges the batch it read: that is what makes the next
|
|
178
180
|
* check bring the next one rather than the same again.
|
|
179
181
|
*/
|
|
@@ -195,9 +197,21 @@ export function checkMail(bots, { bot: botName, session: sessionName, tab, peek
|
|
|
195
197
|
// Read as the session's own tab, wherever this check was typed. Binding the
|
|
196
198
|
// tab that asked would hand it the session's mailbox, and Orca's notice for
|
|
197
199
|
// every message after (issue #228). A session with no live tab is read as
|
|
198
|
-
//
|
|
199
|
-
|
|
200
|
-
|
|
200
|
+
// whatever its mailbox is bound to, a closed tab included, and nothing is
|
|
201
|
+
// bound: the session's tab is bound again when it is back up (issue #249).
|
|
202
|
+
const live = who.tab === undefined ? undefined : tabs(who.home).find((tab) => tab.tabId === who.tab)?.handle;
|
|
203
|
+
if (live !== undefined) useMailbox(who.mailbox, live);
|
|
204
|
+
const handle = live ?? coordinatorOf(who.mailbox);
|
|
205
|
+
if (handle === undefined) {
|
|
206
|
+
return {
|
|
207
|
+
bots,
|
|
208
|
+
bot: who.bot,
|
|
209
|
+
session: who.session,
|
|
210
|
+
mailbox: who.mailbox,
|
|
211
|
+
messages: [],
|
|
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
|
+
};
|
|
214
|
+
}
|
|
201
215
|
const found = readMailbox(who.mailbox, { peek, handle });
|
|
202
216
|
const messages = (found.messages ?? []).map((message) => asMessage(bots, message));
|
|
203
217
|
if (!peek && found.deliveryId !== undefined && messages.length > 0) ackMailbox(who.mailbox, found.deliveryId, handle);
|
|
@@ -321,31 +335,14 @@ function nudge(to, from, subject) {
|
|
|
321
335
|
if (to.tab === undefined) return { nudged: false };
|
|
322
336
|
|
|
323
337
|
try {
|
|
324
|
-
const
|
|
325
|
-
if (
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
if (
|
|
329
|
-
// The shell in front is a tab with no harness, whatever a stale
|
|
330
|
-
// `agentIdentity` says.
|
|
331
|
-
if (seen.front === 'shell') return { nudged: false };
|
|
332
|
-
// Not knowing is not a reason to type: a line that lands in a shell is run
|
|
333
|
-
// there, with the sender's subject in it. The program in front has to be
|
|
334
|
-
// the agent Orca names. With no name it may be a harness a few seconds
|
|
335
|
-
// into its launch; under another name it may be a pager started after the
|
|
336
|
-
// harness quit, under an identity Orca has not let go of (tech notes,
|
|
337
|
-
// section 1). A harness run through a wrapper such as `node` lands here
|
|
338
|
-
// too, until #261.
|
|
339
|
-
if (seen.front === undefined || seen.agent === undefined || seen.command !== seen.agent) {
|
|
340
|
-
const why = seen.front === undefined
|
|
341
|
-
? seen.unreadable
|
|
342
|
-
: `${seen.command} holds its terminal, and Orca names ${seen.agent ?? 'no agent'} in it`;
|
|
343
|
-
return { nudged: false, nudgeTrouble: `the kit could not tell whether a harness is running in it (${why}), so nothing was typed` };
|
|
344
|
-
}
|
|
345
|
-
// A busy harness is typed into: it takes the line as its next turn.
|
|
338
|
+
const found = tabToTypeInto(to.home, to.tab, LOOK_MS);
|
|
339
|
+
if (found.blocked !== undefined) return { nudged: false, blocked: found.blocked };
|
|
340
|
+
// A line that lands in a shell is run there, with the sender's subject in it.
|
|
341
|
+
if (found.unsure !== undefined) return { nudged: false, nudgeTrouble: found.unsure };
|
|
342
|
+
if (found.handle === undefined) return { nudged: false };
|
|
346
343
|
|
|
347
344
|
typeIntoTab(
|
|
348
|
-
|
|
345
|
+
found.handle,
|
|
349
346
|
`Fleet mail from ${from.bot}/${from.session}: ${subject}. Read it with ${shellWord(ownCli())} message check --bots ${shellWord(to.bots)} --bot ${shellWord(to.bot)} --session ${shellWord(to.session)}`,
|
|
350
347
|
);
|
|
351
348
|
return { nudged: true };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Run by Orca's own binary as plain Node, the way Orca's `bin/orca` runs its
|
|
2
|
+
// CLI: `ELECTRON_RUN_AS_NODE=1 <Orca> orca-window.cjs <client> <projectId> <wait>`.
|
|
3
|
+
//
|
|
4
|
+
// It asks Orca's runtime, through Orca's own client, for `project.update` with
|
|
5
|
+
// no changes on one project. That is the call that makes Orca's window read its
|
|
6
|
+
// projects again, and Orca's CLI does not offer it (#224, ADR 0021). It exits 0
|
|
7
|
+
// only when the runtime took the call; anything else is a non-zero exit, and
|
|
8
|
+
// the kit reads every non-zero exit the same way.
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const [client, projectId, wait] = process.argv.slice(2);
|
|
13
|
+
|
|
14
|
+
process.exitCode = 1;
|
|
15
|
+
const { RuntimeClient } = require(client);
|
|
16
|
+
new RuntimeClient(undefined, Number(wait))
|
|
17
|
+
.call('project.update', { projectId, updates: {} })
|
|
18
|
+
.then((answer) => {
|
|
19
|
+
if (answer?.ok === true) process.exitCode = 0;
|
|
20
|
+
}, () => {})
|
|
21
|
+
.finally(() => process.exit());
|
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 0021). 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
|
//
|
|
@@ -8,9 +8,10 @@
|
|
|
8
8
|
// handed to Orca on the command line.
|
|
9
9
|
|
|
10
10
|
import { spawnSync } from 'node:child_process';
|
|
11
|
-
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
11
|
+
import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs';
|
|
12
12
|
import { homedir } from 'node:os';
|
|
13
13
|
import path from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
14
15
|
|
|
15
16
|
// The Orca that works for a normal user: `/usr/local/bin/orca` is a root-only
|
|
16
17
|
// symlink on this machine (tech notes, section 1). OBK_ORCA overrides it, for a
|
|
@@ -162,6 +163,49 @@ export function asFolderProject(setupId, title) {
|
|
|
162
163
|
*/
|
|
163
164
|
export const deleteProject = (setupId) => orca(['project', 'setup-delete', '--setup', setupId]);
|
|
164
165
|
|
|
166
|
+
/** Said after a run that made, renamed or removed a project, whatever `tellWindow` answered. */
|
|
167
|
+
export const RELOAD_LINE = "If Orca's sidebar does not show it, reload the window with Cmd+Shift+R.";
|
|
168
|
+
|
|
169
|
+
/** How long Orca's client waits for its runtime, and how long the kit waits for the client. */
|
|
170
|
+
const CLIENT_WAIT_MS = 2000;
|
|
171
|
+
const CLIENT_KILL_MS = 3000;
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Tell Orca's window that the project `projectId` changed, so it reads its
|
|
175
|
+
* projects again: true when Orca's runtime took the call, false otherwise.
|
|
176
|
+
*
|
|
177
|
+
* Orca's window re-reads only when its runtime says the projects changed, and
|
|
178
|
+
* `setup-update` and `setup-delete` do not say so. `project.update` with no
|
|
179
|
+
* changes does, and Orca's CLI does not offer it, so this goes through Orca's
|
|
180
|
+
* own runtime client out of the installed app, run by Orca's binary the way
|
|
181
|
+
* its `bin/orca` runs its CLI (ADR 0021). None of that is Orca's published
|
|
182
|
+
* interface, so anything that goes wrong is a quiet false: it never throws and
|
|
183
|
+
* is never tried twice, and the caller prints `RELOAD_LINE` either way.
|
|
184
|
+
*/
|
|
185
|
+
export function tellWindow(projectId) {
|
|
186
|
+
try {
|
|
187
|
+
// The CLI sits at <Orca.app>/Contents/Resources/bin/orca, often reached
|
|
188
|
+
// through a link.
|
|
189
|
+
const contents = path.resolve(realpathSync(orcaCli()), '..', '..', '..');
|
|
190
|
+
const client = path.join(contents, 'Resources', 'app.asar.unpacked', 'out', 'cli', 'runtime-client.js');
|
|
191
|
+
const env = { ...process.env, ELECTRON_RUN_AS_NODE: '1' };
|
|
192
|
+
// As Orca's `bin/orca` does: the kit's own Node options are not Orca's.
|
|
193
|
+
delete env.NODE_OPTIONS;
|
|
194
|
+
delete env.NODE_REPL_EXTERNAL_MODULE;
|
|
195
|
+
|
|
196
|
+
const asked = spawnSync(
|
|
197
|
+
path.join(contents, 'MacOS', 'Orca'),
|
|
198
|
+
[WINDOW_SCRIPT, client, projectId, String(CLIENT_WAIT_MS)],
|
|
199
|
+
{ env, stdio: 'ignore', timeout: CLIENT_KILL_MS },
|
|
200
|
+
);
|
|
201
|
+
return asked.error === undefined && asked.status === 0;
|
|
202
|
+
} catch {
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const WINDOW_SCRIPT = fileURLToPath(new URL('./orca-window.cjs', import.meta.url));
|
|
208
|
+
|
|
165
209
|
/**
|
|
166
210
|
* The live tabs of the Orca project at `home`, each under its own tab id.
|
|
167
211
|
*
|
|
@@ -226,6 +270,63 @@ export function harnessInTab(handle, timeoutMs) {
|
|
|
226
270
|
};
|
|
227
271
|
}
|
|
228
272
|
|
|
273
|
+
/**
|
|
274
|
+
* Whether the kit may type a line into `tabId`, the tab the book holds for a
|
|
275
|
+
* session in the Orca project at `home`, and the handle to type into when it
|
|
276
|
+
* may: `{ handle, agent }`, with the agent Orca names there. Otherwise nothing
|
|
277
|
+
* is typed, and the answer says why:
|
|
278
|
+
* `{}` for a tab with no harness in it (none in the book, none Orca lists, or
|
|
279
|
+
* the shell in front), `{ blocked }` for one with something on screen waiting
|
|
280
|
+
* to be answered, and `{ unsure }`, a sentence, for one the kit cannot tell
|
|
281
|
+
* about. Orca refusing throws, for the caller to report.
|
|
282
|
+
*
|
|
283
|
+
* The one gate for every line the kit types into a running session: the mail
|
|
284
|
+
* nudge and the skills reload. A busy harness passes it, since both harnesses
|
|
285
|
+
* take a typed line as their next turn.
|
|
286
|
+
*/
|
|
287
|
+
export function tabToTypeInto(home, tabId, timeoutMs) {
|
|
288
|
+
if (tabId === undefined) return {};
|
|
289
|
+
const live = tabs(home).find((tab) => tab.tabId === tabId);
|
|
290
|
+
if (live === undefined) return {};
|
|
291
|
+
|
|
292
|
+
const seen = harnessInTab(live.handle, timeoutMs);
|
|
293
|
+
// A line typed into a screen waiting for an answer is that answer: once, it
|
|
294
|
+
// confirmed Claude Code's folder-trust default, `No, exit` (tech notes,
|
|
295
|
+
// section 1).
|
|
296
|
+
if (seen.blockedReason !== undefined) return { blocked: seen.blockedReason };
|
|
297
|
+
// The shell in front is a tab with no harness, whatever a stale
|
|
298
|
+
// `agentIdentity` says.
|
|
299
|
+
if (seen.front === 'shell') return {};
|
|
300
|
+
// Not knowing is not a reason to type: a line that lands in a shell is run
|
|
301
|
+
// there. The program in front has to be the agent Orca names. With no name
|
|
302
|
+
// it may be a harness a few seconds into its launch; under another name it
|
|
303
|
+
// may be a pager started after the harness quit, under an identity Orca has
|
|
304
|
+
// not let go of (tech notes, section 1). A harness run through a wrapper such
|
|
305
|
+
// as `node` lands here too, until #261.
|
|
306
|
+
if (seen.front === undefined || seen.agent === undefined || seen.command !== seen.agent) {
|
|
307
|
+
const why = seen.front === undefined
|
|
308
|
+
? seen.unreadable
|
|
309
|
+
: `${seen.command} holds its terminal, and Orca names ${seen.agent ?? 'no agent'} in it`;
|
|
310
|
+
return { unsure: `the kit could not tell whether a harness is running in it (${why}), so nothing was typed` };
|
|
311
|
+
}
|
|
312
|
+
return { handle: live.handle, agent: seen.agent };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Who holds the terminal of the tab `handle`, as `frontOf` answers, without
|
|
317
|
+
* waiting on the tab: for a caller that asks about every tab and has nothing
|
|
318
|
+
* to type. Orca refusing is an answer that cannot be read, not an error.
|
|
319
|
+
*/
|
|
320
|
+
export function frontOfTab(handle) {
|
|
321
|
+
let shown;
|
|
322
|
+
try {
|
|
323
|
+
shown = orca(['terminal', 'show', '--terminal', handle]).terminal;
|
|
324
|
+
} catch (error) {
|
|
325
|
+
return { unreadable: `Orca would not show the tab: ${error.message}` };
|
|
326
|
+
}
|
|
327
|
+
return frontOf(shown?.ptyId);
|
|
328
|
+
}
|
|
329
|
+
|
|
229
330
|
/** The `ps` this run reads. OBK_PS overrides it, as OBK_ORCA does Orca. */
|
|
230
331
|
const psCli = () => process.env.OBK_PS || '/bin/ps';
|
|
231
332
|
|
|
@@ -235,7 +336,7 @@ const psCli = () => process.env.OBK_PS || '/bin/ps';
|
|
|
235
336
|
* group in front, or `{ unreadable: <why> }`.
|
|
236
337
|
*
|
|
237
338
|
* Orca gives the pane's pid in `diagnostics memory` and nowhere else, and `ps`
|
|
238
|
-
* gives that pid's terminal's foreground process group (ADR
|
|
339
|
+
* gives that pid's terminal's foreground process group (ADR 0021).
|
|
239
340
|
* On macOS the pane is `login` with the shell as its child, so the shell is in
|
|
240
341
|
* front when the group is the pane's own or that of a child of a `login` pane.
|
|
241
342
|
* `diagnostics memory` is a diagnostics command and may change, so everything
|
|
@@ -326,17 +427,23 @@ export const makeMailbox = (objective, handle) =>
|
|
|
326
427
|
orca(['orchestration', 'run-create', '--objective', `obk ${objective}`, '--from', handle]).run.id;
|
|
327
428
|
|
|
328
429
|
/**
|
|
329
|
-
*
|
|
330
|
-
*
|
|
331
|
-
*
|
|
332
|
-
*
|
|
333
|
-
*
|
|
334
|
-
* asked, not a lease anybody has to give back. Without a handle it binds this
|
|
335
|
-
* process's own terminal.
|
|
430
|
+
* Bind a mailbox to the terminal `handle`, which makes it the Run's coordinator
|
|
431
|
+
* and its one reader: Orca refuses a `check` from any other terminal with
|
|
432
|
+
* `consumer_fenced`. A read itself binds nothing, so this is for a session's own
|
|
433
|
+
* live tab, never for whoever is reading (tech notes, section 1). Without a
|
|
434
|
+
* handle it binds this process's own terminal.
|
|
336
435
|
*/
|
|
337
436
|
export const useMailbox = (id, handle) =>
|
|
338
437
|
orca(['orchestration', 'run-use', '--id', id, ...(handle === undefined ? [] : ['--from', handle])]).run;
|
|
339
438
|
|
|
439
|
+
/**
|
|
440
|
+
* The terminal a mailbox is bound to, or undefined when it has none. A closed
|
|
441
|
+
* tab keeps its binding, and a read as its handle still works: a read binds
|
|
442
|
+
* nothing, only `run-use` does (tech notes, section 1).
|
|
443
|
+
*/
|
|
444
|
+
export const coordinatorOf = (id) =>
|
|
445
|
+
orca(['orchestration', 'run-show', '--id', id]).run.coordinator_handle ?? undefined;
|
|
446
|
+
|
|
340
447
|
/** Queue one message. `to` and `from` are mailboxes, written `run:<id>`. */
|
|
341
448
|
export function postMessage({ to, from, subject, body, type = 'status', thread }) {
|
|
342
449
|
const args = ['orchestration', 'send', '--to', to, '--subject', subject, '--body', body, '--type', type];
|
package/src/record.js
CHANGED
|
@@ -17,6 +17,7 @@ import { forgetClaimed, readBook, rememberSession, sessionIdsIn, updateBook, wit
|
|
|
17
17
|
import { botDir, readBot } from './bot.js';
|
|
18
18
|
import { conversationsIn } from './conversations.js';
|
|
19
19
|
import { harnessOf, SHELL_ENV, startPrompt, workDirOf } from './launch.js';
|
|
20
|
+
import { rulesStamp } from './rules.js';
|
|
20
21
|
|
|
21
22
|
/** Where Orca says which tab a program is running in (tech notes, section 1). */
|
|
22
23
|
export const TAB_ENV = 'ORCA_TAB_ID';
|
|
@@ -79,6 +80,11 @@ export async function recordSession(bots, name, said, tabId, shellPid) {
|
|
|
79
80
|
// this bot's folder, which every session of the bot shares — so it can say
|
|
80
81
|
// that a conversation nobody claims exists, and never whose it is.
|
|
81
82
|
const unclaimed = unclaimedFor(readBook(home), home, bot, tabId, id);
|
|
83
|
+
// A clear on Claude Code reads the bot's instructions again, as a start does
|
|
84
|
+
// (tech notes, section 2), so the book notes which ones it read. Codex has no
|
|
85
|
+
// word for its clear, and whether its new conversation reads them again is
|
|
86
|
+
// not established, so nothing is noted for it.
|
|
87
|
+
const rules = said.source === CLEARED ? rulesStamp(home) : undefined;
|
|
82
88
|
|
|
83
89
|
let cleared = false;
|
|
84
90
|
let told;
|
|
@@ -100,7 +106,8 @@ export async function recordSession(bots, name, said, tabId, shellPid) {
|
|
|
100
106
|
const uncertain = was.session === undefined && unclaimed.length > 0;
|
|
101
107
|
told = session;
|
|
102
108
|
|
|
103
|
-
|
|
109
|
+
const now = rememberSession(was, id, said.source);
|
|
110
|
+
book.sessions[session] = withUnclaimed(rules === undefined ? now : { ...now, rules }, unclaimed);
|
|
104
111
|
forgetClaimed(book);
|
|
105
112
|
if (uncertain) cleared = true;
|
|
106
113
|
return book;
|
package/src/retire.js
CHANGED
|
@@ -16,16 +16,16 @@
|
|
|
16
16
|
// restart or a pause it does not wait for the book to know which one it was.
|
|
17
17
|
// The mailbox Runs stay: Orca has no way to remove one (ADR 0018).
|
|
18
18
|
|
|
19
|
-
import { existsSync, mkdirSync, renameSync, rmSync } from 'node:fs';
|
|
19
|
+
import { existsSync, mkdirSync, realpathSync, renameSync, rmSync } from 'node:fs';
|
|
20
20
|
import path from 'node:path';
|
|
21
21
|
|
|
22
22
|
import { readBook, tabIdsIn, updateBook } from './book.js';
|
|
23
23
|
import { botDir, dropSession, readBot } from './bot.js';
|
|
24
|
-
import { deleteProject, findProject, tabs } from './orca.js';
|
|
24
|
+
import { deleteProject, findProject, projects, tabs, tellWindow } from './orca.js';
|
|
25
25
|
import { fleetMember } from './pause.js';
|
|
26
26
|
import { closeTabs, tabsToClose } from './restart.js';
|
|
27
27
|
import { unlinkSkills } from './skills.js';
|
|
28
|
-
import { promptPath, sessionsOf } from './up.js';
|
|
28
|
+
import { BOT_FATHER, promptPath, sessionsOf } from './up.js';
|
|
29
29
|
|
|
30
30
|
/** Where retired bots go: beside `bots/`, where nothing the kit runs looks. */
|
|
31
31
|
export const retiredDir = (bots) => path.join(bots, 'retired');
|
|
@@ -55,6 +55,8 @@ export async function retireSession(bots, { bot, session }) {
|
|
|
55
55
|
/**
|
|
56
56
|
* Retire the bot `bot`. Returns `{ bot, closed, project, moved }`: the tabs it
|
|
57
57
|
* closed, the Orca project it took away (if it had one) and where the bot is now.
|
|
58
|
+
* When Orca does not confirm the project gone, it returns `{ bot, closed,
|
|
59
|
+
* project, trouble }` instead, and the bot is left where it was.
|
|
58
60
|
*/
|
|
59
61
|
export async function retireBot(bots, { bot }) {
|
|
60
62
|
const home = fleetMember(bots, bot, 'retire');
|
|
@@ -79,7 +81,24 @@ export async function retireBot(bots, { bot }) {
|
|
|
79
81
|
// they are gone.
|
|
80
82
|
const booked = Object.keys(readBook(home).sessions).map((name) => ({ name }));
|
|
81
83
|
const closed = await closeTabs(home, tabsToClose(bots, bot, home, booked, { keepless: true }), bots, bot);
|
|
82
|
-
if (project !== undefined)
|
|
84
|
+
if (project !== undefined) {
|
|
85
|
+
deleteProject(project.id);
|
|
86
|
+
// Orca's answer to the delete is not the same as the project being gone
|
|
87
|
+
// (#282): its list afterwards is. Until that list is read and no longer
|
|
88
|
+
// has the project, the bot stays where it is, so that retiring it again
|
|
89
|
+
// takes the project away and then finishes. The list is read once, for
|
|
90
|
+
// this and for the window both.
|
|
91
|
+
let setups;
|
|
92
|
+
try {
|
|
93
|
+
setups = projects();
|
|
94
|
+
} catch (error) {
|
|
95
|
+
return { bot, closed, project: project.id, trouble: `Orca answered the delete of Orca project ${project.id}, and its project list could not be read afterwards, so the removal is not confirmed: ${error.message}. ${bot} was not moved. Once Orca is answering, retire ${bot} again with obk retire: it removes the project if it is still there, then finishes.` };
|
|
96
|
+
}
|
|
97
|
+
if (setups.some((setup) => setup.id === project.id || setup.path === home)) {
|
|
98
|
+
return { bot, closed, project: project.id, trouble: `Orca answered the delete of Orca project ${project.id}, and still lists it. ${bot} was not moved. Retire ${bot} again with obk retire; if Orca still lists the project after that, remove it in Orca yourself, then retire ${bot} again.` };
|
|
99
|
+
}
|
|
100
|
+
tellWindowOfRemoval(bots, setups);
|
|
101
|
+
}
|
|
83
102
|
|
|
84
103
|
for (const name of new Set([...known.sessions, ...booked].map((session) => session.name))) {
|
|
85
104
|
rmSync(promptPath(bots, bot, name), { force: true });
|
|
@@ -90,3 +109,19 @@ export async function retireBot(bots, { bot }) {
|
|
|
90
109
|
|
|
91
110
|
return { bot, closed, ...(project === undefined ? {} : { project: project.id }), moved };
|
|
92
111
|
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Tell Orca's window a project went (#224). The call names a project that is
|
|
115
|
+
* still there, and Bot Father's is the one that always is; when Orca has none
|
|
116
|
+
* for it in `setups`, Orca's list after the delete, there is nothing to call
|
|
117
|
+
* on. Finding it is part of the workaround, so it fails as quietly as the call.
|
|
118
|
+
*/
|
|
119
|
+
function tellWindowOfRemoval(bots, setups) {
|
|
120
|
+
try {
|
|
121
|
+
const home = botDir(bots, BOT_FATHER);
|
|
122
|
+
const father = existsSync(home) ? setups.find((setup) => setup.path === realpathSync(home)) : undefined;
|
|
123
|
+
if (father !== undefined) tellWindow(father.projectId);
|
|
124
|
+
} catch {
|
|
125
|
+
// Nothing: see above.
|
|
126
|
+
}
|
|
127
|
+
}
|
package/src/rules.js
CHANGED
|
@@ -406,3 +406,18 @@ function sameFile(one, other) {
|
|
|
406
406
|
return false;
|
|
407
407
|
}
|
|
408
408
|
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* A stamp of a bot's `AGENTS.md` as it is now, or undefined when there is none
|
|
412
|
+
* to read. It is what a session read when it started, noted in the book at that
|
|
413
|
+
* moment, so that health can tell a session running on older rules from one on
|
|
414
|
+
* the current ones. The whole file, not only the build's block: what the user
|
|
415
|
+
* wrote below the end marker is part of what the session reads.
|
|
416
|
+
*/
|
|
417
|
+
export function rulesStamp(home) {
|
|
418
|
+
try {
|
|
419
|
+
return checksum(readFileSync(path.join(home, AGENTS), 'utf8'));
|
|
420
|
+
} catch {
|
|
421
|
+
return undefined;
|
|
422
|
+
}
|
|
423
|
+
}
|