@assuredloop/orca-bot-kit 0.3.0 → 0.4.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 +16 -3
- package/SETUP.md +3 -2
- package/package.json +1 -1
- package/skills/obk-bot-building/SKILL.md +23 -11
- package/src/cli.js +58 -13
- package/src/message.js +25 -28
- package/src/orca-window.cjs +21 -0
- package/src/orca.js +102 -10
- package/src/retire.js +39 -4
- package/src/skills.js +107 -16
- package/src/up.js +18 -9
package/README.md
CHANGED
|
@@ -263,12 +263,22 @@ name for a directory in `skills/` inside your bots folder, or anything with a
|
|
|
263
263
|
`/` in it for a skill directory anywhere on disk. `obk up` links them before it
|
|
264
264
|
opens a tab, and `bot create` gives a new bot what the lists already name.
|
|
265
265
|
|
|
266
|
+
A bot whose sessions are running gets a changed list without a restart.
|
|
267
|
+
When `skills build` links or takes away a skill, it tells each running
|
|
268
|
+
session of that bot. A Claude Code session gets `/reload-skills` typed into
|
|
269
|
+
its tab: Claude Code's own reload, which a busy session runs when its turn
|
|
270
|
+
ends. Codex has no reload command and takes the change at the start of its
|
|
271
|
+
next turn, so nothing is typed there. The kit prints the new skills'
|
|
272
|
+
`SKILL.md` paths, and says that a restart would make a skill appear if it is
|
|
273
|
+
still missing after that turn. A session the kit could not tell is named,
|
|
274
|
+
with the reason.
|
|
275
|
+
|
|
266
276
|
What lands in the bot is a symlink, never a copy
|
|
267
277
|
([ADR 0014](docs/adr/0014-skills-are-linked-never-copied.md)) — into
|
|
268
278
|
`.claude/skills` and `.agents/skills` in the bot home, which is where each
|
|
269
279
|
harness reads a project's skills from. So a kit skill is read where npm
|
|
270
|
-
installed it,
|
|
271
|
-
|
|
280
|
+
installed it, a running session reads an edited skill the next time it loads
|
|
281
|
+
it, and updating the kit updates every bot at once.
|
|
272
282
|
|
|
273
283
|
The kit takes away only what it can prove it put there. It writes down every
|
|
274
284
|
link it makes, in `.obk-skills.yaml` in the bot folder, and when no list names
|
|
@@ -404,7 +414,10 @@ same refusals, and `obk up` leaves what is paused closed until `obk unpause`
|
|
|
404
414
|
brings it back with its conversations. Retiring a session takes it off the bot
|
|
405
415
|
and keeps its conversations in the book under `retired`. Retiring a bot closes
|
|
406
416
|
its tabs, removes its Orca project and moves its folder to `retired/`; it will
|
|
407
|
-
not touch a bot whose Orca project holds a tab your book does not name.
|
|
417
|
+
not touch a bot whose Orca project holds a tab your book does not name. The
|
|
418
|
+
folder moves only once Orca no longer lists the project; if Orca still lists it,
|
|
419
|
+
or its list cannot be read, retire says so and leaves the bot where it is, and
|
|
420
|
+
retiring it again finishes the job. Bot
|
|
408
421
|
Father itself, and its management session `daily`, are never paused or
|
|
409
422
|
retired; its other sessions are like any bot's.
|
|
410
423
|
|
package/SETUP.md
CHANGED
|
@@ -145,8 +145,9 @@ different answers.
|
|
|
145
145
|
|
|
146
146
|
`obk init` tells you which, for every tab it opened:
|
|
147
147
|
|
|
148
|
-
- *the harness was typed in and
|
|
149
|
-
|
|
148
|
+
- *the harness was typed in and is running. The kit cannot see whether a screen
|
|
149
|
+
in it is waiting for an answer* — look at it: Orca does not flag every first-run
|
|
150
|
+
screen, so it may still be asking something of its own. That is the table below.
|
|
150
151
|
- *the harness was typed in and came up, waiting on: …* — running and blocked on
|
|
151
152
|
a question. The table below.
|
|
152
153
|
- *the harness was typed in, and no session came up in the tab* — **the line did
|
package/package.json
CHANGED
|
@@ -273,17 +273,29 @@ again and check a session is running before you call the bot ready.
|
|
|
273
273
|
## When the bot is already running
|
|
274
274
|
|
|
275
275
|
Changing a bot whose sessions are up is the ordinary case rather than the
|
|
276
|
-
awkward one.
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
276
|
+
awkward one. Putting a skill on a bot or taking one off reaches its running
|
|
277
|
+
sessions without a restart, and `obk skills build` says, session by session,
|
|
278
|
+
how:
|
|
279
|
+
|
|
280
|
+
- Claude Code: the kit types `/reload-skills` into the session's tab. That is
|
|
281
|
+
Claude Code's own reload. A busy session queues it and runs it as a command
|
|
282
|
+
when its turn ends, so it never reaches the model as text.
|
|
283
|
+
- Codex: nothing is typed, because Codex has no reload command. It takes the
|
|
284
|
+
change at the start of its next turn, not in the middle of the one it is on.
|
|
285
|
+
|
|
286
|
+
The kit reports a session it could not tell, and why: not up, or on a screen
|
|
287
|
+
waiting for an answer. A session that is not up reads its skills when it
|
|
288
|
+
starts.
|
|
289
|
+
|
|
290
|
+
Told is not the same as confirmed. Before telling anyone a skill is there,
|
|
291
|
+
check that the session can use it: ask it to load the skill. Do not look
|
|
292
|
+
again at the disk you just wrote, since that says nothing about what another
|
|
293
|
+
process has loaded. Do not ask a Codex session whether the skill is in its
|
|
294
|
+
list: it has answered "none" while its list held the skill. Where a skill has
|
|
295
|
+
not appeared after the next turn, say so. A restart would make it appear, and
|
|
296
|
+
meanwhile the session can read the skill's `SKILL.md` by the path the kit
|
|
297
|
+
printed. A restart is the user's to ask for. Taking one because it is quick
|
|
298
|
+
ends a conversation they were in the middle of.
|
|
287
299
|
|
|
288
300
|
Instructions are not skills. A session is working from the rules it read when
|
|
289
301
|
it started, so a change to a bot's `AGENTS.md` may not reach one that is
|
package/src/cli.js
CHANGED
|
@@ -18,7 +18,7 @@ import { checkHealth, orcaSettingFindings } from './health.js';
|
|
|
18
18
|
import { initBots } from './init.js';
|
|
19
19
|
import { APPROVALS, HARNESSES, ownCli, shellWord, workDirOf } from './launch.js';
|
|
20
20
|
import { checkMail, lookUp, sendMessage } from './message.js';
|
|
21
|
-
import { orcaCli, orcaTrouble } from './orca.js';
|
|
21
|
+
import { orcaCli, orcaTrouble, RELOAD_LINE } from './orca.js';
|
|
22
22
|
import { pauseSessions, unpauseSessions } from './pause.js';
|
|
23
23
|
import { recordSession, SHELL_ENV, TAB_ENV } from './record.js';
|
|
24
24
|
import { restartSessions } from './restart.js';
|
|
@@ -83,7 +83,8 @@ Usage:
|
|
|
83
83
|
a bot: close its tabs, remove its Orca project and
|
|
84
84
|
move its folder to retired/. It will not retire a
|
|
85
85
|
bot whose Orca project holds a tab your book does
|
|
86
|
-
not name
|
|
86
|
+
not name, and moves the folder only once Orca no
|
|
87
|
+
longer lists the project.
|
|
87
88
|
obk rules build --bots <path> [--bot <bot>]
|
|
88
89
|
Build every bot's AGENTS.md from its charter and
|
|
89
90
|
the rule units it carries, or just the one you
|
|
@@ -103,7 +104,10 @@ Usage:
|
|
|
103
104
|
skills folder, or any path, and take away a link
|
|
104
105
|
it made that no list names any more. What you put
|
|
105
106
|
in a bot's skills directory yourself is left alone
|
|
106
|
-
and shown as yours.
|
|
107
|
+
and shown as yours. A bot whose links changed has
|
|
108
|
+
its running sessions told: Claude Code gets
|
|
109
|
+
/reload-skills typed in, and Codex takes the
|
|
110
|
+
change at its next turn.
|
|
107
111
|
obk skills fetch --bots <path> [--source <name>]
|
|
108
112
|
Clone the online sources skills.yaml lists, beside
|
|
109
113
|
your bots folder and never inside it, each at the
|
|
@@ -425,19 +429,19 @@ const commands = {
|
|
|
425
429
|
const seeded = initBots(bots, values.harness);
|
|
426
430
|
// The typed path was for making the folder and naming it in a refusal; from
|
|
427
431
|
// here on it is the fleet, and the fleet is its real path (#164).
|
|
428
|
-
const { tabs, rules, skills, paused } = await bringUp(sameFleet(seeded.bots), { bot: BOT_FATHER });
|
|
432
|
+
const { tabs, rules, skills, paused, projects } = await bringUp(sameFleet(seeded.bots), { bot: BOT_FATHER });
|
|
429
433
|
// Setup is the other place the PRD asks for Orca's own launch arguments to
|
|
430
434
|
// be looked at (6.5), and the one where the user is still standing in front
|
|
431
435
|
// of the fleet they are making. Only that one check: a folder init has just
|
|
432
436
|
// made has nothing else to say about itself.
|
|
433
|
-
const answer = { bots: seeded.bots, created: seeded.created, completed: seeded.completed, rules, skills, tabs, paused, found: orcaSettingFindings() };
|
|
437
|
+
const answer = { bots: seeded.bots, created: seeded.created, completed: seeded.completed, rules, skills, tabs, paused, projects, found: orcaSettingFindings() };
|
|
434
438
|
return { answer, lines: tabLines(answer, `Bot Father is up in Orca. Your bots folder: ${seeded.bots}`) };
|
|
435
439
|
},
|
|
436
440
|
|
|
437
441
|
async up(bots, values) {
|
|
438
442
|
refuseWhenOrcaIsDown();
|
|
439
|
-
const { tabs, rules, skills, paused } = await bringUp(bots, { bot: values.bot, session: values.session });
|
|
440
|
-
const answer = { bots, created: [], completed: [], rules, skills, tabs, paused };
|
|
443
|
+
const { tabs, rules, skills, paused, projects } = await bringUp(bots, { bot: values.bot, session: values.session });
|
|
444
|
+
const answer = { bots, created: [], completed: [], rules, skills, tabs, paused, projects };
|
|
441
445
|
const up = [...new Set(tabs.map((tab) => tab.bot))];
|
|
442
446
|
const summary = up.length === 0
|
|
443
447
|
? `Nothing was brought up in Orca. Your bots folder: ${bots}`
|
|
@@ -464,8 +468,8 @@ const commands = {
|
|
|
464
468
|
|
|
465
469
|
async restart(bots, values) {
|
|
466
470
|
refuseWhenOrcaIsDown();
|
|
467
|
-
const { closed, tabs, rules, skills, paused } = await restartSessions(bots, { bot: values.bot, session: values.session });
|
|
468
|
-
const answer = { bots, created: [], completed: [], rules, skills, tabs, paused, closed };
|
|
471
|
+
const { closed, tabs, rules, skills, paused, projects } = await restartSessions(bots, { bot: values.bot, session: values.session });
|
|
472
|
+
const answer = { bots, created: [], completed: [], rules, skills, tabs, paused, projects, closed };
|
|
469
473
|
const what = values.session === undefined ? values.bot : `${values.bot} ${values.session}`;
|
|
470
474
|
return {
|
|
471
475
|
answer,
|
|
@@ -518,11 +522,25 @@ const commands = {
|
|
|
518
522
|
}
|
|
519
523
|
|
|
520
524
|
const retired = await retireBot(bots, { bot: values.bot });
|
|
525
|
+
if (retired.trouble !== undefined) {
|
|
526
|
+
return {
|
|
527
|
+
answer: { bots, ...retired },
|
|
528
|
+
lines: [
|
|
529
|
+
...closedLines(retired.closed),
|
|
530
|
+
`${'trouble'.padEnd(9)} Orca project ${retired.project}`,
|
|
531
|
+
` ${retired.trouble}`,
|
|
532
|
+
`Retire it again: ${shellWord(ownCli())} retire --bots ${shellWord(bots)} --bot ${retired.bot}`,
|
|
533
|
+
],
|
|
534
|
+
code: 1,
|
|
535
|
+
};
|
|
536
|
+
}
|
|
521
537
|
return {
|
|
522
538
|
answer: { bots, ...retired },
|
|
523
539
|
lines: [
|
|
524
540
|
...closedLines(retired.closed),
|
|
525
|
-
|
|
541
|
+
// After every removal, whatever the window was told: Orca's window can
|
|
542
|
+
// keep a removed project's row even after it re-reads (stablyai/orca#20102).
|
|
543
|
+
...(retired.project === undefined ? [] : [`removed Orca project ${retired.project}`, RELOAD_LINE]),
|
|
526
544
|
`retired ${retired.bot}: moved to ${path.relative(bots, retired.moved)}, with its book, charter and memory`,
|
|
527
545
|
],
|
|
528
546
|
};
|
|
@@ -1084,9 +1102,24 @@ function skillsLines(skills) {
|
|
|
1084
1102
|
...(entry.trouble === undefined ? [] : [` ${'trouble'.padEnd(9)} ${entry.trouble}`]),
|
|
1085
1103
|
...entry.skills.map((skill) => ` ${(skill.managed ? 'linked' : 'yours').padEnd(9)} ${skill.name.padEnd(24)} ${skill.from ?? 'no list names it; the kit leaves it alone'}`),
|
|
1086
1104
|
...(entry.removed ?? []).map((name) => ` ${'removed'.padEnd(9)} ${name.padEnd(24)} no list names it now`),
|
|
1105
|
+
...(entry.sessions ?? []).map((one) => toldLine(entry.bot, one)),
|
|
1087
1106
|
]);
|
|
1088
1107
|
}
|
|
1089
1108
|
|
|
1109
|
+
/** What `skills build` did about one running session of a bot whose skills changed. */
|
|
1110
|
+
function toldLine(bot, { session, harness, state, read, blocked, trouble }) {
|
|
1111
|
+
const who = `${bot}/${session}`;
|
|
1112
|
+
const after = harness === 'claude' ? 'Once that is settled, type /reload-skills in its tab.' : 'Its next turn takes the change by itself.';
|
|
1113
|
+
const [word, says] = {
|
|
1114
|
+
reloaded: ['reloaded', '/reload-skills was typed into its tab; a busy session runs it when its turn ends'],
|
|
1115
|
+
'next-turn': ['next turn', `Codex takes the change at the start of its next turn. If a skill is not there then, a restart makes it appear${read?.length > 0 ? `; meanwhile it can read ${read.join(', ')}` : ''}`],
|
|
1116
|
+
'not-up': ['not told', 'not up; it reads its skills when it starts'],
|
|
1117
|
+
blocked: ['not told', `its tab is waiting for an answer (${blocked}), so nothing was typed. ${after}`],
|
|
1118
|
+
unknown: ['not told', `${trouble}. ${after}`],
|
|
1119
|
+
}[state];
|
|
1120
|
+
return ` ${word.padEnd(9)} ${who.padEnd(24)} ${says}`;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1090
1123
|
/**
|
|
1091
1124
|
* What a check found, two lines each: what kind of trouble it is and the one
|
|
1092
1125
|
* thing to go and look at, then the sentence about it. The same shape wherever
|
|
@@ -1095,7 +1128,7 @@ function skillsLines(skills) {
|
|
|
1095
1128
|
const foundLines = (found) =>
|
|
1096
1129
|
found.flatMap((one) => [`${one.kind.padEnd(9)} ${one.where}`, ` ${one.says}`]);
|
|
1097
1130
|
|
|
1098
|
-
function tabLines({ bots, created, completed, rules, skills, tabs, paused = [], found = [] }, summary) {
|
|
1131
|
+
function tabLines({ bots, created, completed, rules, skills, tabs, paused = [], projects = [], found = [] }, summary) {
|
|
1099
1132
|
const lines = [
|
|
1100
1133
|
...created.map((entry) => `created ${entry}`),
|
|
1101
1134
|
...completed.map((entry) => `completed ${entry}`),
|
|
@@ -1115,7 +1148,10 @@ function tabLines({ bots, created, completed, rules, skills, tabs, paused = [],
|
|
|
1115
1148
|
|
|
1116
1149
|
// Last before the summary, because what a check found is about the setup the
|
|
1117
1150
|
// run has just left behind rather than about any one thing it did.
|
|
1118
|
-
lines.push(...foundLines(found)
|
|
1151
|
+
lines.push(...foundLines(found));
|
|
1152
|
+
// Hedged, because nothing here can see whether the window was told (#224).
|
|
1153
|
+
if (projects.length > 0) lines.push(RELOAD_LINE);
|
|
1154
|
+
lines.push(summary);
|
|
1119
1155
|
return lines;
|
|
1120
1156
|
}
|
|
1121
1157
|
|
|
@@ -1144,8 +1180,17 @@ function harnessLines(tab, bots) {
|
|
|
1144
1180
|
];
|
|
1145
1181
|
}
|
|
1146
1182
|
|
|
1183
|
+
// Orca naming no reason does not mean nothing is on screen: on 1.4.209 it
|
|
1184
|
+
// names none for Codex's trust and hooks screens, nor for Claude Code's trust
|
|
1185
|
+
// list (tech notes, section 1, #288). So the kit says what it cannot see.
|
|
1147
1186
|
const lines = tab.blockedReason === undefined
|
|
1148
|
-
? [
|
|
1187
|
+
? [
|
|
1188
|
+
...how,
|
|
1189
|
+
' the harness was typed in and is running. The kit cannot see whether a screen',
|
|
1190
|
+
' in it is waiting for an answer.',
|
|
1191
|
+
` Look at it: ${lookAt(tab.terminal)}`,
|
|
1192
|
+
...ANSWER_IT,
|
|
1193
|
+
]
|
|
1149
1194
|
: [
|
|
1150
1195
|
...how,
|
|
1151
1196
|
` the harness was typed in and came up, waiting on: ${tab.blockedReason}`,
|
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,48 @@ 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
|
+
|
|
229
315
|
/** The `ps` this run reads. OBK_PS overrides it, as OBK_ORCA does Orca. */
|
|
230
316
|
const psCli = () => process.env.OBK_PS || '/bin/ps';
|
|
231
317
|
|
|
@@ -235,7 +321,7 @@ const psCli = () => process.env.OBK_PS || '/bin/ps';
|
|
|
235
321
|
* group in front, or `{ unreadable: <why> }`.
|
|
236
322
|
*
|
|
237
323
|
* 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
|
|
324
|
+
* gives that pid's terminal's foreground process group (ADR 0021).
|
|
239
325
|
* On macOS the pane is `login` with the shell as its child, so the shell is in
|
|
240
326
|
* front when the group is the pane's own or that of a child of a `login` pane.
|
|
241
327
|
* `diagnostics memory` is a diagnostics command and may change, so everything
|
|
@@ -326,17 +412,23 @@ export const makeMailbox = (objective, handle) =>
|
|
|
326
412
|
orca(['orchestration', 'run-create', '--objective', `obk ${objective}`, '--from', handle]).run.id;
|
|
327
413
|
|
|
328
414
|
/**
|
|
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.
|
|
415
|
+
* Bind a mailbox to the terminal `handle`, which makes it the Run's coordinator
|
|
416
|
+
* and its one reader: Orca refuses a `check` from any other terminal with
|
|
417
|
+
* `consumer_fenced`. A read itself binds nothing, so this is for a session's own
|
|
418
|
+
* live tab, never for whoever is reading (tech notes, section 1). Without a
|
|
419
|
+
* handle it binds this process's own terminal.
|
|
336
420
|
*/
|
|
337
421
|
export const useMailbox = (id, handle) =>
|
|
338
422
|
orca(['orchestration', 'run-use', '--id', id, ...(handle === undefined ? [] : ['--from', handle])]).run;
|
|
339
423
|
|
|
424
|
+
/**
|
|
425
|
+
* The terminal a mailbox is bound to, or undefined when it has none. A closed
|
|
426
|
+
* tab keeps its binding, and a read as its handle still works: a read binds
|
|
427
|
+
* nothing, only `run-use` does (tech notes, section 1).
|
|
428
|
+
*/
|
|
429
|
+
export const coordinatorOf = (id) =>
|
|
430
|
+
orca(['orchestration', 'run-show', '--id', id]).run.coordinator_handle ?? undefined;
|
|
431
|
+
|
|
340
432
|
/** Queue one message. `to` and `from` are mailboxes, written `run:<id>`. */
|
|
341
433
|
export function postMessage({ to, from, subject, body, type = 'status', thread }) {
|
|
342
434
|
const args = ['orchestration', 'send', '--to', to, '--subject', subject, '--body', body, '--type', type];
|
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/skills.js
CHANGED
|
@@ -3,25 +3,30 @@
|
|
|
3
3
|
//
|
|
4
4
|
// Linked, never copied, in both directions. Inwards: what lands in the bot home
|
|
5
5
|
// is a symlink to the skill's own directory, so a kit skill is read where the
|
|
6
|
-
// package installed it and
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
6
|
+
// package installed it, and both harnesses follow the link. A running session
|
|
7
|
+
// reads an edited skill the next time it loads it. A skill linked in or taken
|
|
8
|
+
// away reaches it without a restart. Claude Code takes it through its own
|
|
9
|
+
// `/reload-skills`, which `obk skills build` types into the session's tab.
|
|
10
|
+
// Codex has no such command and takes it at the start of its next turn (tech
|
|
11
|
+
// notes, sections 2 and 3). Outwards: the kit takes away only the links it put
|
|
12
|
+
// there, and never the skill one was pointing at.
|
|
10
13
|
//
|
|
11
14
|
// An entry names one of four shelves: the kit's own (`kit:<name>`), an online
|
|
12
15
|
// source `skills.yaml` lists (`<source>:<name>`, read from its clone beside the
|
|
13
16
|
// bots folder), the user's common folder (a bare name), or a path to anywhere
|
|
14
17
|
// on disk.
|
|
15
18
|
|
|
16
|
-
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
17
20
|
import os from 'node:os';
|
|
18
21
|
import path from 'node:path';
|
|
19
22
|
import { fileURLToPath } from 'node:url';
|
|
20
23
|
|
|
21
24
|
import { parse, parseDocument, stringify } from 'yaml';
|
|
22
25
|
|
|
26
|
+
import { bookFile, readBook } from './book.js';
|
|
23
27
|
import { botDir, botNames, changesExactly, leadsOutside, readBot, YAML_OUT } from './bot.js';
|
|
24
|
-
import { ownCli, shellWord } from './launch.js';
|
|
28
|
+
import { harnessOf, ownCli, shellWord } from './launch.js';
|
|
29
|
+
import { orcaCli, orcaTrouble, tabToTypeInto, typeIntoTab } from './orca.js';
|
|
25
30
|
import { listIn } from './rules.js';
|
|
26
31
|
import { cloneDir, isCloned, readSources, skillsIn, wrongClone } from './sources.js';
|
|
27
32
|
|
|
@@ -130,7 +135,7 @@ export function removeSkill(bots, bot, ref) {
|
|
|
130
135
|
|
|
131
136
|
/**
|
|
132
137
|
* Give one bot the skills its lists name. Returns what there is to report:
|
|
133
|
-
* `{ bot, skills, removed }` for a bot whose links are in place, and
|
|
138
|
+
* `{ bot, skills, linked, removed }` for a bot whose links are in place, and
|
|
134
139
|
* `{ bot, skills, trouble }` for one whose list the kit could not follow.
|
|
135
140
|
*
|
|
136
141
|
* A list with one entry the kit cannot follow links nothing at all: half a
|
|
@@ -210,6 +215,7 @@ export function linkSkills(bots, home, bot) {
|
|
|
210
215
|
// something to throw through `prepareBots` and stop the fleet. What was
|
|
211
216
|
// linked before it went wrong is still written down, so the next run knows it
|
|
212
217
|
// for the kit's own.
|
|
218
|
+
const linked = [];
|
|
213
219
|
const removed = [];
|
|
214
220
|
try {
|
|
215
221
|
for (const harness of Object.keys(SKILL_DIRS)) {
|
|
@@ -218,8 +224,8 @@ export function linkSkills(bots, home, bot) {
|
|
|
218
224
|
held.set(harness, heldIn(path.join(home, SKILL_DIRS[harness]), record[harness]));
|
|
219
225
|
continue;
|
|
220
226
|
}
|
|
221
|
-
|
|
222
|
-
|
|
227
|
+
// Filled as it goes, so what was changed before a failure is still known.
|
|
228
|
+
const done = link(path.join(home, SKILL_DIRS[harness]), wanted, record[harness], linked, removed);
|
|
223
229
|
held.set(harness, done.held);
|
|
224
230
|
}
|
|
225
231
|
} catch (error) {
|
|
@@ -232,6 +238,9 @@ export function linkSkills(bots, home, bot) {
|
|
|
232
238
|
return {
|
|
233
239
|
bot: bot.name,
|
|
234
240
|
skills: heldBy(home, [], held),
|
|
241
|
+
// What did change before it went wrong, so the sessions it reaches are told.
|
|
242
|
+
linked: [...new Set(linked)].sort(),
|
|
243
|
+
removed: [...new Set(removed)].sort(),
|
|
235
244
|
trouble: `${bot.name}'s skills could not be linked: ${error.message}. Nothing of yours was changed; put right what is named there, then build again.`,
|
|
236
245
|
};
|
|
237
246
|
}
|
|
@@ -243,6 +252,7 @@ export function linkSkills(bots, home, bot) {
|
|
|
243
252
|
return {
|
|
244
253
|
bot: bot.name,
|
|
245
254
|
skills,
|
|
255
|
+
linked: [...new Set(linked)].sort(),
|
|
246
256
|
removed: [...new Set(removed)].sort(),
|
|
247
257
|
trouble: [...away].map(([harness, real]) => `${path.join(home, SKILL_DIRS[harness])} leads outside the bot folder, to ${real}, through a link, and the kit links skills only inside the bot folder, so ${harness} does not get the skills the lists name. Replace the link with a directory of the bot's own, then build again.`).join(' '),
|
|
248
258
|
};
|
|
@@ -251,6 +261,7 @@ export function linkSkills(bots, home, bot) {
|
|
|
251
261
|
return {
|
|
252
262
|
bot: bot.name,
|
|
253
263
|
skills,
|
|
264
|
+
linked: [...new Set(linked)].sort(),
|
|
254
265
|
removed: [...new Set(removed)].sort(),
|
|
255
266
|
// A name the lists ask for that the bot has something else under. The user's
|
|
256
267
|
// own is never written over, so what they have to know is that the list did
|
|
@@ -391,7 +402,8 @@ function namesIn(dir) {
|
|
|
391
402
|
|
|
392
403
|
/**
|
|
393
404
|
* Give every bot in the folder its skills, or the one named, in name order.
|
|
394
|
-
* This is the whole of `obk skills build`: it writes links and
|
|
405
|
+
* This is the whole of `obk skills build`: it writes links, and a bot whose
|
|
406
|
+
* links changed has its running sessions told (`tellSessions`).
|
|
395
407
|
*/
|
|
396
408
|
export function buildSkills(bots, { bot: onlyBot } = {}) {
|
|
397
409
|
const names = botNames(bots);
|
|
@@ -401,11 +413,89 @@ export function buildSkills(bots, { bot: onlyBot } = {}) {
|
|
|
401
413
|
|
|
402
414
|
return (onlyBot === undefined ? names : [onlyBot]).map((name) => {
|
|
403
415
|
const home = botDir(bots, name);
|
|
416
|
+
let bot;
|
|
417
|
+
let entry;
|
|
404
418
|
try {
|
|
405
|
-
|
|
419
|
+
bot = readBot(home, name);
|
|
420
|
+
entry = linkSkills(bots, home, bot);
|
|
406
421
|
} catch (error) {
|
|
407
422
|
return { bot: name, skills: [], trouble: error.message };
|
|
408
423
|
}
|
|
424
|
+
const changed = (entry.linked?.length ?? 0) + (entry.removed?.length ?? 0) > 0;
|
|
425
|
+
return changed ? { ...entry, sessions: tellSessions(home, bot, entry.linked) } : entry;
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** How long Orca is given to say what is in a session's tab. */
|
|
430
|
+
const LOOK_MS = 2000;
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Tell each session of a bot whose skill links just changed, through its
|
|
434
|
+
* harness's own means (PRD 6.5: changes reach running sessions without a
|
|
435
|
+
* restart, and the sessions a change affects are told). Returns one entry per
|
|
436
|
+
* session, in the bot's order: `{ session, harness, state }`, where the state
|
|
437
|
+
* is `reloaded`, `next-turn` (with `read`), `not-up`, `blocked` (with
|
|
438
|
+
* `blocked`) or `unknown` (with `trouble`).
|
|
439
|
+
*
|
|
440
|
+
* Claude Code gets `/reload-skills` typed into its tab, through the same gate
|
|
441
|
+
* as the mail nudge. A busy session queues it and runs it as a command when
|
|
442
|
+
* its turn ends, so it never reaches the model as text. Codex has no reload
|
|
443
|
+
* command and takes the change at the start of its next turn by itself, so
|
|
444
|
+
* nothing is typed, and the caller is given the SKILL.md of each skill linked
|
|
445
|
+
* in, for the session to read if the skill has not appeared (tech notes,
|
|
446
|
+
* sections 2 and 3). Nothing is ever restarted.
|
|
447
|
+
*/
|
|
448
|
+
function tellSessions(home, bot, linked) {
|
|
449
|
+
let book;
|
|
450
|
+
try {
|
|
451
|
+
book = readBook(home).sessions;
|
|
452
|
+
} catch (error) {
|
|
453
|
+
// The links are made; which tab is whose is what cannot be known.
|
|
454
|
+
const trouble = `${bookFile(home)} cannot be read (${error.message}), so the kit could not tell which tab is this session's, and nothing was typed`;
|
|
455
|
+
return bot.sessions.map((session) => ({ session: session.name, harness: harnessOf(session, bot.harness), state: 'unknown', trouble }));
|
|
456
|
+
}
|
|
457
|
+
// Resolved, because Orca is asked about this folder by path and does not
|
|
458
|
+
// follow a link to it (tech notes, section 1).
|
|
459
|
+
let real;
|
|
460
|
+
try {
|
|
461
|
+
real = realpathSync(home);
|
|
462
|
+
} catch {
|
|
463
|
+
real = home;
|
|
464
|
+
}
|
|
465
|
+
// An Orca whose runtime is not there still answers some commands from what
|
|
466
|
+
// it last knew, so it is asked whether it is up before it is asked anything
|
|
467
|
+
// about a tab.
|
|
468
|
+
const down = bot.sessions.some((session) => typeof book[session.name]?.tab === 'string') ? orcaTrouble() : undefined;
|
|
469
|
+
|
|
470
|
+
return bot.sessions.map((session) => {
|
|
471
|
+
const harness = harnessOf(session, bot.harness);
|
|
472
|
+
const told = { session: session.name, harness };
|
|
473
|
+
const tab = typeof book[session.name]?.tab === 'string' ? book[session.name].tab : undefined;
|
|
474
|
+
if (tab === undefined) return { ...told, state: 'not-up' };
|
|
475
|
+
if (down !== undefined) return { ...told, state: 'unknown', trouble: `Orca is not answering at ${orcaCli()}, so the kit could not look at its tab, and nothing was typed` };
|
|
476
|
+
try {
|
|
477
|
+
const found = tabToTypeInto(real, tab, LOOK_MS);
|
|
478
|
+
if (found.blocked !== undefined) return { ...told, state: 'blocked', blocked: found.blocked };
|
|
479
|
+
if (found.unsure !== undefined) return { ...told, state: 'unknown', trouble: found.unsure };
|
|
480
|
+
if (found.handle === undefined) return { ...told, state: 'not-up' };
|
|
481
|
+
// The book's tab, now running the other harness: its reload is not this one's.
|
|
482
|
+
if (found.agent !== harness) {
|
|
483
|
+
return { ...told, state: 'unknown', trouble: `Orca names ${found.agent} in its tab, not ${harness}, so nothing was typed` };
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (harness === 'codex') {
|
|
487
|
+
// Only what Codex can actually read: a link can fail for one harness
|
|
488
|
+
// alone, and a skills directory that is the user's gets none.
|
|
489
|
+
const read = linked.map((name) => path.join(home, SKILL_DIRS.codex, name, MANIFEST)).filter((file) => existsSync(file));
|
|
490
|
+
return { ...told, state: 'next-turn', read };
|
|
491
|
+
}
|
|
492
|
+
typeIntoTab(found.handle, '/reload-skills');
|
|
493
|
+
return { ...told, state: 'reloaded' };
|
|
494
|
+
} catch (error) {
|
|
495
|
+
// The links are made whatever Orca says, so this is reported, not
|
|
496
|
+
// thrown: the build did what it was asked, and the session was not told.
|
|
497
|
+
return { ...told, state: 'unknown', trouble: error.message };
|
|
498
|
+
}
|
|
409
499
|
});
|
|
410
500
|
}
|
|
411
501
|
|
|
@@ -489,7 +579,9 @@ function follow(bots, ref, sources) {
|
|
|
489
579
|
|
|
490
580
|
/**
|
|
491
581
|
* Make one harness's skills directory hold what `wanted` says, as far as the
|
|
492
|
-
* kit is allowed to. Returns `{ removed, held }` — the names it
|
|
582
|
+
* kit is allowed to. Returns `{ linked, removed, held }` — the names it linked
|
|
583
|
+
* in or repointed, the names it took away (added to the lists given, as it
|
|
584
|
+
* goes, so a caller whose run throws still has them), and
|
|
493
585
|
* what is in the directory afterwards.
|
|
494
586
|
*
|
|
495
587
|
* The kit acts only where the entry on disk is still the link it wrote, which
|
|
@@ -497,12 +589,10 @@ function follow(bots, ref, sources) {
|
|
|
497
589
|
* link the very same skill by hand — so the kit remembers what it made rather
|
|
498
590
|
* than guessing from where a link points (PRD 6.7).
|
|
499
591
|
*/
|
|
500
|
-
function link(dir, wanted, record) {
|
|
592
|
+
function link(dir, wanted, record, linked = [], removed = []) {
|
|
501
593
|
// The directory is there: `linkSkills` makes both before it calls this.
|
|
502
594
|
|
|
503
595
|
const keep = new Map(wanted.map((skill) => [skill.name, skill]));
|
|
504
|
-
const removed = [];
|
|
505
|
-
|
|
506
596
|
// What the lists no longer name. The kit takes back only its own, and only
|
|
507
597
|
// while it is untouched; anything else it forgets about and leaves.
|
|
508
598
|
for (const name of Object.keys(record)) {
|
|
@@ -541,9 +631,10 @@ function link(dir, wanted, record) {
|
|
|
541
631
|
// repo, and the link has to reach out of the repo to find it (ADR 0014).
|
|
542
632
|
symlinkSync(skill.dir, at);
|
|
543
633
|
record[skill.name] = skill.dir;
|
|
634
|
+
linked.push(skill.name);
|
|
544
635
|
}
|
|
545
636
|
|
|
546
|
-
return { removed, held: heldIn(dir, record) };
|
|
637
|
+
return { linked, removed, held: heldIn(dir, record) };
|
|
547
638
|
}
|
|
548
639
|
|
|
549
640
|
/**
|
package/src/up.js
CHANGED
|
@@ -12,7 +12,7 @@ import { botDir, botNames, displayName, readBot } from './bot.js';
|
|
|
12
12
|
import { conversationsIn } from './conversations.js';
|
|
13
13
|
import { installHook } from './hooks.js';
|
|
14
14
|
import { addressOf, harnessOf, isShortPrompt, launchCommand, reachesMail, sessionTrouble, startPrompt, workDirOf } from './launch.js';
|
|
15
|
-
import { asFolderProject, findProject, harnessInTab, makeMailbox, makeProject, openTab, retitleTab, tabs, typeIntoTab, useMailbox } from './orca.js';
|
|
15
|
+
import { asFolderProject, findProject, harnessInTab, makeMailbox, makeProject, openTab, retitleTab, tabs, tellWindow, typeIntoTab, useMailbox } from './orca.js';
|
|
16
16
|
import { buildAgents } from './rules.js';
|
|
17
17
|
import { linkSkills } from './skills.js';
|
|
18
18
|
|
|
@@ -35,6 +35,7 @@ const SECOND_LOOK_MS = 2000;
|
|
|
35
35
|
* Bring bots up in Orca. Returns `{ tabs, rules }`: one tab entry per tab it
|
|
36
36
|
* looked at — the sessions the book knows, and, for Bot Father, whatever else
|
|
37
37
|
* is open in its project — and one rules entry and one skills entry per bot.
|
|
38
|
+
* `projects` has one entry per Orca project this run made or renamed.
|
|
38
39
|
*
|
|
39
40
|
* With no name it is every bot in the folder, in name order. `bot` brings up
|
|
40
41
|
* one bot and `session` one of its sessions, for a caller that wants one thing
|
|
@@ -58,11 +59,14 @@ export async function bringUp(bots, { bot: onlyBot, session: onlySession } = {})
|
|
|
58
59
|
const { running, rules, skills } = prepareBots(bots, names, onlySession);
|
|
59
60
|
|
|
60
61
|
const report = [];
|
|
62
|
+
const projects = [];
|
|
61
63
|
for (const { bot, home } of running) {
|
|
62
64
|
for (const session of sessionsOf(bot, onlySession)) {
|
|
63
65
|
if (session.paused === true) paused.push({ bot: bot.name, session: session.name });
|
|
64
66
|
}
|
|
65
|
-
|
|
67
|
+
const up = await bringUpBot(bots, home, bot, onlySession);
|
|
68
|
+
report.push(...up.tabs);
|
|
69
|
+
if (up.project !== undefined) projects.push(up.project);
|
|
66
70
|
}
|
|
67
71
|
|
|
68
72
|
// A bot whose rules would not build is reported here rather than in the
|
|
@@ -78,6 +82,7 @@ export async function bringUp(bots, { bot: onlyBot, session: onlySession } = {})
|
|
|
78
82
|
})),
|
|
79
83
|
skills,
|
|
80
84
|
paused,
|
|
85
|
+
projects,
|
|
81
86
|
};
|
|
82
87
|
}
|
|
83
88
|
|
|
@@ -197,8 +202,11 @@ async function bringUpBot(bots, home, bot, onlySession) {
|
|
|
197
202
|
// Orca is asked first and the book is written after: nothing that takes time
|
|
198
203
|
// happens while the book is held, because a session's own hook may be writing
|
|
199
204
|
// its id into that same file at any moment.
|
|
200
|
-
const orca = orcaProject(home, title);
|
|
205
|
+
const { change, ...orca } = orcaProject(home, title);
|
|
201
206
|
await updateBook(home, (book) => { book.orca = orca; });
|
|
207
|
+
// Orca's window does not see a project made or renamed on the command line
|
|
208
|
+
// until it is told (#224).
|
|
209
|
+
if (change !== undefined) tellWindow(orca.project);
|
|
202
210
|
|
|
203
211
|
const live = new Map(tabs(home).map((tab) => [tab.tabId, tab]));
|
|
204
212
|
const report = [];
|
|
@@ -217,7 +225,7 @@ async function bringUpBot(bots, home, bot, onlySession) {
|
|
|
217
225
|
: spare.map((tab) => entry(tab, { bot: name, name: null, created: false }))));
|
|
218
226
|
}
|
|
219
227
|
|
|
220
|
-
return report;
|
|
228
|
+
return { tabs: report, project: change === undefined ? undefined : { bot: name, project: orca.project, change } };
|
|
221
229
|
}
|
|
222
230
|
|
|
223
231
|
async function bringUpSession(bots, home, live, session, bot, title) {
|
|
@@ -455,7 +463,8 @@ function whichConversation(book, home, bot, session, was, harness) {
|
|
|
455
463
|
}
|
|
456
464
|
|
|
457
465
|
/**
|
|
458
|
-
* A bot's Orca project, made if it is not there yet
|
|
466
|
+
* A bot's Orca project, made if it is not there yet, with `change` saying
|
|
467
|
+
* whether this run `made` it, `renamed` it or left it as it was (undefined).
|
|
459
468
|
*
|
|
460
469
|
* It has to be a folder workspace. Registering a folder that sits inside the
|
|
461
470
|
* bots repo gets it recorded as a git one, which Orca gives no workspace at
|
|
@@ -463,12 +472,12 @@ function whichConversation(book, home, bot, session, was, harness) {
|
|
|
463
472
|
*/
|
|
464
473
|
function orcaProject(home, title) {
|
|
465
474
|
const found = findProject(home);
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
: (found.kind === 'folder' ? found : asFolderProject(found.id, title));
|
|
469
|
-
return { project: setup.projectId, setup: setup.id };
|
|
475
|
+
if (found === undefined) return ids(makeProject(home, title), 'made');
|
|
476
|
+
return found.kind === 'folder' ? ids(found) : ids(asFolderProject(found.id, title), 'renamed');
|
|
470
477
|
}
|
|
471
478
|
|
|
479
|
+
const ids = (setup, change) => ({ project: setup.projectId, setup: setup.id, change });
|
|
480
|
+
|
|
472
481
|
/**
|
|
473
482
|
* Where the kit leaves a prompt for the launch line to pick up: a folder of its
|
|
474
483
|
* own beside the bots repo, the way skill-source clones sit beside it (PRD 6.3)
|