@assuredloop/orca-bot-kit 0.1.0 → 0.3.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/LICENSE +21 -0
- package/README.md +548 -3
- package/SETUP.md +275 -0
- package/package.json +32 -13
- package/rules/evidence.md +15 -0
- package/rules/finishing.md +23 -0
- package/rules/limits.md +18 -0
- package/rules/mail.md +17 -0
- package/rules/profiles.md +9 -0
- package/rules/review.md +14 -0
- package/rules/scope.md +22 -0
- package/rules/simple.md +14 -0
- package/rules/talk.md +13 -0
- package/rules/tests-first.md +17 -0
- package/rules/the-ask.md +19 -0
- package/skills/obk-arch/LICENSES.md +52 -0
- package/skills/obk-arch/NOTICE.md +242 -0
- package/skills/obk-arch/SKILL.md +598 -0
- package/skills/obk-bot-building/LICENSES.md +21 -0
- package/skills/obk-bot-building/NOTICE.md +113 -0
- package/skills/obk-bot-building/SKILL.md +312 -0
- package/skills/obk-debugging/LICENSES.md +67 -0
- package/skills/obk-debugging/NOTICE.md +115 -0
- package/skills/obk-debugging/SKILL.md +434 -0
- package/skills/obk-decision-memo/LICENSES.md +49 -0
- package/skills/obk-decision-memo/NOTICE.md +181 -0
- package/skills/obk-decision-memo/SKILL.md +241 -0
- package/skills/obk-finops/LICENSES.md +19 -0
- package/skills/obk-finops/NOTICE.md +77 -0
- package/skills/obk-finops/SKILL.md +148 -0
- package/skills/obk-fleet-review/LICENSES.md +52 -0
- package/skills/obk-fleet-review/NOTICE.md +104 -0
- package/skills/obk-fleet-review/SKILL.md +256 -0
- package/skills/obk-grilling/LICENSES.md +42 -0
- package/skills/obk-grilling/NOTICE.md +111 -0
- package/skills/obk-grilling/SKILL.md +221 -0
- package/skills/obk-grooming/LICENSES.md +52 -0
- package/skills/obk-grooming/NOTICE.md +111 -0
- package/skills/obk-grooming/SKILL.md +222 -0
- package/skills/obk-handoff/LICENSES.md +45 -0
- package/skills/obk-handoff/NOTICE.md +51 -0
- package/skills/obk-handoff/SKILL.md +119 -0
- package/skills/obk-personal-facilitation/LICENSES.md +53 -0
- package/skills/obk-personal-facilitation/NOTICE.md +62 -0
- package/skills/obk-personal-facilitation/SKILL.md +86 -0
- package/skills/obk-recall/LICENSES.md +42 -0
- package/skills/obk-recall/NOTICE.md +53 -0
- package/skills/obk-recall/SKILL.md +129 -0
- package/skills/obk-researching/LICENSES.md +67 -0
- package/skills/obk-researching/NOTICE.md +170 -0
- package/skills/obk-researching/SKILL.md +269 -0
- package/skills/obk-reviewing/LICENSES.md +66 -0
- package/skills/obk-reviewing/NOTICE.md +58 -0
- package/skills/obk-reviewing/SKILL.md +411 -0
- package/skills/obk-tdd/LICENSES.md +68 -0
- package/skills/obk-tdd/NOTICE.md +54 -0
- package/skills/obk-tdd/SKILL.md +547 -0
- package/skills/obk-tdd/mutation-tools.md +80 -0
- package/skills/obk-teach/LICENSES.md +42 -0
- package/skills/obk-teach/NOTICE.md +65 -0
- package/skills/obk-teach/SKILL.md +106 -0
- package/skills/obk-why/LICENSES.md +42 -0
- package/skills/obk-why/NOTICE.md +74 -0
- package/skills/obk-why/SKILL.md +180 -0
- package/skills/obk-writing/LICENSES.md +272 -0
- package/skills/obk-writing/NOTICE.md +161 -0
- package/skills/obk-writing/SKILL.md +254 -0
- package/src/book.js +268 -0
- package/src/bot.js +451 -0
- package/src/cli.js +1169 -0
- package/src/conversations.js +150 -0
- package/src/groom.js +159 -0
- package/src/health.js +309 -0
- package/src/hooks.js +257 -0
- package/src/init.js +314 -0
- package/src/launch.js +299 -0
- package/src/message.js +402 -0
- package/src/orca.js +362 -0
- package/src/pause.js +63 -0
- package/src/record.js +209 -0
- package/src/restart.js +193 -0
- package/src/retire.js +92 -0
- package/src/roster.js +71 -0
- package/src/rules.js +408 -0
- package/src/skills.js +630 -0
- package/src/sources.js +371 -0
- package/src/up.js +500 -0
- package/src/usage.js +401 -0
- package/bin/okb.js +0 -4
- package/src/index.js +0 -11
package/src/up.js
ADDED
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
// `obk up`: make the bots real in Orca, and put back whatever is missing.
|
|
2
|
+
//
|
|
3
|
+
// It only ever adds. It never closes a tab: closing one throws away the user's
|
|
4
|
+
// conversation and Orca's resume record with it. Run it twice and the second
|
|
5
|
+
// run does nothing.
|
|
6
|
+
|
|
7
|
+
import { existsSync, mkdirSync, realpathSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
|
|
10
|
+
import { forgetClaimed, readBook, sessionIdsIn, tabIdsIn, updateBook, withUnclaimed } from './book.js';
|
|
11
|
+
import { botDir, botNames, displayName, readBot } from './bot.js';
|
|
12
|
+
import { conversationsIn } from './conversations.js';
|
|
13
|
+
import { installHook } from './hooks.js';
|
|
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';
|
|
16
|
+
import { buildAgents } from './rules.js';
|
|
17
|
+
import { linkSkills } from './skills.js';
|
|
18
|
+
|
|
19
|
+
/** The one bot with a tab beside its sessions: the ops tab (PRD 6.2). */
|
|
20
|
+
export const BOT_FATHER = 'bot-father';
|
|
21
|
+
|
|
22
|
+
/** How long a harness is given to draw its first screen before the kit gives up on it. */
|
|
23
|
+
const STARTUP_MS = 10000;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* And how long the second look takes. A harness can draw its first screen and
|
|
27
|
+
* then die on something it was handed — a context window it cannot read, an
|
|
28
|
+
* extra argument it does not know — and the first look would have called it
|
|
29
|
+
* started. An idle harness answers this at once; a busy one and a shell both
|
|
30
|
+
* wait it out, and it is the tab's foreground that tells them apart (#232).
|
|
31
|
+
*/
|
|
32
|
+
const SECOND_LOOK_MS = 2000;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Bring bots up in Orca. Returns `{ tabs, rules }`: one tab entry per tab it
|
|
36
|
+
* looked at — the sessions the book knows, and, for Bot Father, whatever else
|
|
37
|
+
* is open in its project — and one rules entry and one skills entry per bot.
|
|
38
|
+
*
|
|
39
|
+
* With no name it is every bot in the folder, in name order. `bot` brings up
|
|
40
|
+
* one bot and `session` one of its sessions, for a caller that wants one thing
|
|
41
|
+
* back rather than the fleet.
|
|
42
|
+
*/
|
|
43
|
+
export async function bringUp(bots, { bot: onlyBot, session: onlySession } = {}) {
|
|
44
|
+
const named = botsNamed(bots, onlyBot);
|
|
45
|
+
if (onlySession !== undefined && onlyBot === undefined) {
|
|
46
|
+
throw new Error('--session needs --bot: say which bot the session belongs to.');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// A paused bot is left closed, rules, skills, tabs and all, and said to be:
|
|
50
|
+
// it was paused on purpose, and `obk unpause` is what brings it back.
|
|
51
|
+
const paused = [];
|
|
52
|
+
const names = named.filter((name) => {
|
|
53
|
+
if (readBot(botDir(bots, name), name).paused !== true) return true;
|
|
54
|
+
paused.push({ bot: name });
|
|
55
|
+
return false;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const { running, rules, skills } = prepareBots(bots, names, onlySession);
|
|
59
|
+
|
|
60
|
+
const report = [];
|
|
61
|
+
for (const { bot, home } of running) {
|
|
62
|
+
for (const session of sessionsOf(bot, onlySession)) {
|
|
63
|
+
if (session.paused === true) paused.push({ bot: bot.name, session: session.name });
|
|
64
|
+
}
|
|
65
|
+
report.push(...await bringUpBot(bots, home, bot, onlySession));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// A bot whose rules would not build is reported here rather than in the
|
|
69
|
+
// preparation, because "its sessions were not started" is this command's
|
|
70
|
+
// answer and not a fact about the bot: a restart prepares the same way and
|
|
71
|
+
// has its own thing to say about it.
|
|
72
|
+
const started = new Set(running.map(({ bot }) => bot.name));
|
|
73
|
+
return {
|
|
74
|
+
tabs: report,
|
|
75
|
+
rules: rules.map((entry) => (started.has(entry.bot) ? entry : {
|
|
76
|
+
...entry,
|
|
77
|
+
trouble: `${entry.trouble} Its sessions were not started: a bot comes up with its rules or not at all.`,
|
|
78
|
+
})),
|
|
79
|
+
skills,
|
|
80
|
+
paused,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Everything the bots need in place before a session starts, and which of them
|
|
86
|
+
* can be started at all: `{ running, rules, skills }`.
|
|
87
|
+
*
|
|
88
|
+
* Separate from the tabs because the order matters twice over. A session reads
|
|
89
|
+
* its rules, its skills and its hooks as it comes up, so all three have to be
|
|
90
|
+
* there before the tab is (PRD 6.6, ADR 0020) — and a restart closes a tab in
|
|
91
|
+
* between, so everything that can refuse must have refused before that. It
|
|
92
|
+
* refuses by throwing, exactly as `up` always has; a bot it leaves out of
|
|
93
|
+
* `running` is one whose sessions must not be started.
|
|
94
|
+
*/
|
|
95
|
+
export function prepareBots(bots, names, onlySession) {
|
|
96
|
+
// Every bot that is coming up is read and judged before Orca is asked for
|
|
97
|
+
// anything at all: a fleet with one session the kit cannot start is a fleet
|
|
98
|
+
// the user fixes in one edit, not one they find half opened.
|
|
99
|
+
const chosen = names.map((name) => {
|
|
100
|
+
const home = realpathSync(botDir(bots, name));
|
|
101
|
+
return { home, bot: readBot(home, name) };
|
|
102
|
+
});
|
|
103
|
+
for (const { bot, home } of chosen) refuseWhatCannotStart(bot, home, onlySession);
|
|
104
|
+
|
|
105
|
+
// Every bot's AGENTS.md is built before Orca is asked for anything, because a
|
|
106
|
+
// session reads that file as it starts: a build landing after the tab was
|
|
107
|
+
// opened would reach nobody until the next restart (PRD 6.6). It is built at
|
|
108
|
+
// the path the user spelled rather than the resolved one, which is Orca's,
|
|
109
|
+
// so a folder reached through a link is not reported as a road out of the
|
|
110
|
+
// bots folder and back in.
|
|
111
|
+
//
|
|
112
|
+
// A bot the build left with no instructions file at all does not come up:
|
|
113
|
+
// its charter is the boundary it acts inside, and a session started without
|
|
114
|
+
// one has no boundary, which is worse than a session that is not running. A
|
|
115
|
+
// file the user edited themselves is another matter — that bot has its
|
|
116
|
+
// instructions, they are simply theirs, and it comes up like any other.
|
|
117
|
+
//
|
|
118
|
+
// Its skills are linked first, and for the same reason: both harnesses read a
|
|
119
|
+
// project's skills out of the folder the session starts in, so a session that
|
|
120
|
+
// came up before the links were made would be a bot missing the techniques it
|
|
121
|
+
// was given. A skills list the kit cannot follow is reported and does not hold
|
|
122
|
+
// the bot down — skills are what a bot is good at, not the boundary it works
|
|
123
|
+
// inside.
|
|
124
|
+
const skills = chosen.map(({ bot }) => linkSkills(bots, botDir(bots, bot.name), bot));
|
|
125
|
+
|
|
126
|
+
const rules = [];
|
|
127
|
+
const running = [];
|
|
128
|
+
for (const chose of chosen) {
|
|
129
|
+
const built = buildAgents(bots, botDir(bots, chose.bot.name), chose.bot);
|
|
130
|
+
rules.push(built);
|
|
131
|
+
if (existsSync(built.file)) running.push(chose);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// And the kit's hook goes into every bot folder before Orca is asked for
|
|
135
|
+
// anything, for the same reason: a harness reads its hooks when it comes up,
|
|
136
|
+
// so one written later would miss the session it was written for (ADR 0020),
|
|
137
|
+
// and a bot folder the kit cannot write it into stops the run with nothing
|
|
138
|
+
// opened anywhere. Only the harnesses a bot actually runs on; a bot with no
|
|
139
|
+
// sessions gets none.
|
|
140
|
+
for (const { bot, home } of running) {
|
|
141
|
+
for (const harness of new Set(awake(sessionsOf(bot, onlySession)).map((session) => harnessOf(session, bot.harness)))) {
|
|
142
|
+
installHook(home, harness, { bots, bot: bot.name });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { running, rules, skills };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The bots a command is about: every one in the folder, or the one it named.
|
|
151
|
+
*
|
|
152
|
+
* Here because every command that acts on Orca asks the same two questions
|
|
153
|
+
* first, and a user who mistypes a bot's name is owed the same sentence
|
|
154
|
+
* whichever of them they ran.
|
|
155
|
+
*/
|
|
156
|
+
export function botsNamed(bots, onlyBot) {
|
|
157
|
+
const names = botNames(bots);
|
|
158
|
+
if (names.length === 0) {
|
|
159
|
+
throw new Error(`there are no bots in ${bots} yet: make one with obk bot create --bots <path> --name <name> --harness claude|codex.`);
|
|
160
|
+
}
|
|
161
|
+
if (onlyBot !== undefined && !names.includes(onlyBot)) {
|
|
162
|
+
throw new Error(`there is no bot called ${onlyBot} in ${bots}. The bots there are: ${names.join(', ')}.`);
|
|
163
|
+
}
|
|
164
|
+
return onlyBot === undefined ? names : [onlyBot];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function refuseWhatCannotStart(bot, home, onlySession) {
|
|
168
|
+
for (const session of awake(sessionsOf(bot, onlySession))) {
|
|
169
|
+
const trouble = sessionTrouble(session, harnessOf(session, bot.harness), home);
|
|
170
|
+
if (trouble !== undefined) throw new Error(`${bot.name}: ${trouble}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The sessions that are not paused. A paused session is left out of a run
|
|
176
|
+
* altogether, so what it is set to asks nothing of the ones that will start;
|
|
177
|
+
* it is judged again when it is unpaused.
|
|
178
|
+
*/
|
|
179
|
+
const awake = (sessions) => sessions.filter((session) => session.paused !== true);
|
|
180
|
+
|
|
181
|
+
/** The sessions a run is bringing up: all of the bot's, or the one it named. */
|
|
182
|
+
export function sessionsOf(bot, onlySession) {
|
|
183
|
+
if (onlySession === undefined) return bot.sessions;
|
|
184
|
+
|
|
185
|
+
const named = bot.sessions.filter((session) => session.name === onlySession);
|
|
186
|
+
if (named.length === 0) {
|
|
187
|
+
throw new Error(`${bot.name} has no session called ${onlySession}. Add it with obk session add, or name one it has.`);
|
|
188
|
+
}
|
|
189
|
+
return named;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function bringUpBot(bots, home, bot, onlySession) {
|
|
193
|
+
const name = bot.name;
|
|
194
|
+
const title = displayName(name);
|
|
195
|
+
const sessions = awake(sessionsOf(bot, onlySession));
|
|
196
|
+
|
|
197
|
+
// Orca is asked first and the book is written after: nothing that takes time
|
|
198
|
+
// happens while the book is held, because a session's own hook may be writing
|
|
199
|
+
// its id into that same file at any moment.
|
|
200
|
+
const orca = orcaProject(home, title);
|
|
201
|
+
await updateBook(home, (book) => { book.orca = orca; });
|
|
202
|
+
|
|
203
|
+
const live = new Map(tabs(home).map((tab) => [tab.tabId, tab]));
|
|
204
|
+
const report = [];
|
|
205
|
+
for (const session of sessions) report.push(await bringUpSession(bots, home, live, session, bot, title));
|
|
206
|
+
|
|
207
|
+
// The ops tab, and the whole of what the kit knows about it: Bot Father's
|
|
208
|
+
// project needs one tab that is not a session, for work across the fleet. Any
|
|
209
|
+
// tab outside the book is that tab — the kit does not ask who made it, does
|
|
210
|
+
// not write it down and does not touch it again. No other bot has one, and a
|
|
211
|
+
// run asked for one session is not the run to go looking.
|
|
212
|
+
if (name === BOT_FATHER && onlySession === undefined) {
|
|
213
|
+
const sessionTabs = tabIdsIn(readBook(home));
|
|
214
|
+
const spare = [...live.values()].filter((tab) => !sessionTabs.has(tab.tabId));
|
|
215
|
+
report.push(...(spare.length === 0
|
|
216
|
+
? [entry(openTab(home, `${title} ops`), { bot: name, name: null, created: true })]
|
|
217
|
+
: spare.map((tab) => entry(tab, { bot: name, name: null, created: false }))));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return report;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function bringUpSession(bots, home, live, session, bot, title) {
|
|
224
|
+
const harness = harnessOf(session, bot.harness);
|
|
225
|
+
const book = readBook(home);
|
|
226
|
+
const was = book.sessions[session.name];
|
|
227
|
+
const known = live.get(was?.tab);
|
|
228
|
+
const tabTitle = `${title} ${session.name}`;
|
|
229
|
+
|
|
230
|
+
if (known) {
|
|
231
|
+
// A mailbox it can be written to, and no name. The name is the one thing
|
|
232
|
+
// the kit cannot give a session that is already running: `-n` goes on the
|
|
233
|
+
// launch line, this session was launched without one, and nothing renames a
|
|
234
|
+
// live harness. Writing the name down here would advertise an address that
|
|
235
|
+
// answers to nobody (review of PR #132, finding 1). It gets one the next
|
|
236
|
+
// time it starts, which is the next time the kit types its launch line.
|
|
237
|
+
await ensureMailbox(home, bot, session, harness, known.handle);
|
|
238
|
+
|
|
239
|
+
// Whatever runs in the tab may have rewritten its title. The kit writes its
|
|
240
|
+
// own back, and reports that one rather than the name Orca last saw: the id
|
|
241
|
+
// is the session, and a title is only ever set. Nothing is typed into a tab
|
|
242
|
+
// that is already there — the session is in the middle of its work.
|
|
243
|
+
retitleTab(known.handle, tabTitle);
|
|
244
|
+
return entry({ ...known, title: tabTitle }, { bot: bot.name, name: session.name, created: false });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Everything that can be refused is settled before Orca is asked for
|
|
248
|
+
// anything, so a session the kit cannot start leaves no tab behind.
|
|
249
|
+
const workDir = workDirOf(session, home);
|
|
250
|
+
|
|
251
|
+
// Which conversation this session is. A session the kit can name is picked up
|
|
252
|
+
// where it left off, with the conversation it had: the tab is gone, but the
|
|
253
|
+
// harness still has the session. Its duty was given to it once and is not
|
|
254
|
+
// given again (PRD 6.4) — after a clear it is, and that is the hook's work.
|
|
255
|
+
// Asked before the book is held, because it reads a folder of the harness's
|
|
256
|
+
// own files: nothing slow happens under the lock.
|
|
257
|
+
const which = whichConversation(book, home, bot, session, was, harness);
|
|
258
|
+
const resume = which.resume;
|
|
259
|
+
const prompt = resume === undefined ? startPrompt(session, { home, workDir }) : undefined;
|
|
260
|
+
// Anything longer than a line goes to the harness out of a file, rather than
|
|
261
|
+
// through the tab's shell a character at a time.
|
|
262
|
+
const promptFile = prompt === undefined || isShortPrompt(prompt) ? undefined : promptPath(bots, bot.name, session.name);
|
|
263
|
+
const command = launchCommand(session, {
|
|
264
|
+
harness,
|
|
265
|
+
home,
|
|
266
|
+
workDir,
|
|
267
|
+
prompt,
|
|
268
|
+
promptFile,
|
|
269
|
+
resume,
|
|
270
|
+
address: harness === 'claude' ? addressOf(bot.name, session.name) : undefined,
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// A work dir is a plain folder, made for the session before it is told about
|
|
274
|
+
// it (PRD 6.4). Nothing here is a git worktree.
|
|
275
|
+
if (workDir !== undefined) mkdirSync(workDir, { recursive: true });
|
|
276
|
+
|
|
277
|
+
// The prompt is written where the launch line can read it from, before that
|
|
278
|
+
// line is typed. It is the kit's own file, not the user's: theirs stays where
|
|
279
|
+
// they put it, in the bot home.
|
|
280
|
+
if (promptFile !== undefined) {
|
|
281
|
+
mkdirSync(path.dirname(promptFile), { recursive: true });
|
|
282
|
+
writeFileSync(promptFile, prompt);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const made = openTab(home, tabTitle);
|
|
286
|
+
|
|
287
|
+
// Written down the moment it exists, before anything that can fail. A tab
|
|
288
|
+
// whose id never reached the book is a tab nobody owns: the next run would
|
|
289
|
+
// start a second harness beside it and take this one for the spare. What the
|
|
290
|
+
// book already knew about the session stays — including an id written by a
|
|
291
|
+
// hook while this run was busy with Orca; only the tab, the time this run
|
|
292
|
+
// started a harness in it, and a conversation taken over from the harness's
|
|
293
|
+
// own record are new.
|
|
294
|
+
const launched = new Date().toISOString();
|
|
295
|
+
await updateBook(home, (current) => {
|
|
296
|
+
// What the harness has in this folder that nobody claims goes on the record,
|
|
297
|
+
// for a person or Bot Father to settle — added to whatever was already noted,
|
|
298
|
+
// because this run's scan cannot see what an earlier one found. The kit never
|
|
299
|
+
// settles it itself.
|
|
300
|
+
const entry = { ...current.sessions[session.name], tab: made.tabId, launched };
|
|
301
|
+
current.sessions[session.name] = withUnclaimed(entry, which.unclaimed ?? []);
|
|
302
|
+
forgetClaimed(current);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// Typing it in is the way: for a project the kit has just made, giving Orca
|
|
306
|
+
// the harness as the tab's own command times out and leaves a dead tab.
|
|
307
|
+
typeIntoTab(made.handle, command);
|
|
308
|
+
|
|
309
|
+
// Now the session has an address, and not before: the mailbox it can be
|
|
310
|
+
// written to, and — on Claude Code — the name that line just gave it, which
|
|
311
|
+
// is the name another Claude session writes to. Both are written after the
|
|
312
|
+
// tab is on the books, so a mailbox Orca will not make leaves a tab the next
|
|
313
|
+
// run finds and finishes rather than a tab nobody owns (review of PR #132,
|
|
314
|
+
// finding 3).
|
|
315
|
+
await ensureMailbox(home, bot, session, harness, made.handle, { named: harness === 'claude', opened: true });
|
|
316
|
+
|
|
317
|
+
// And then asking whether a TUI came up, rather than assuming one did. The
|
|
318
|
+
// text goes into the tab's own shell, which may have been busy with a
|
|
319
|
+
// question of its own and swallowed the first characters of it.
|
|
320
|
+
//
|
|
321
|
+
// Twice, because coming up and staying up are different things: a harness
|
|
322
|
+
// that refuses what it was handed draws a screen, prints its complaint and
|
|
323
|
+
// leaves a shell behind, and a run that looked once would report it as
|
|
324
|
+
// started.
|
|
325
|
+
const first = lookFor(made.handle, STARTUP_MS);
|
|
326
|
+
const tui = first.running ? lookFor(made.handle, SECOND_LOOK_MS) : first;
|
|
327
|
+
|
|
328
|
+
// The start prompt went in with that line, as the harness's own prompt
|
|
329
|
+
// argument, so it is the harness that holds it until it is ready — through
|
|
330
|
+
// its folder-trust question and its update offer. What is still worth saying
|
|
331
|
+
// is whether the line took at all: a shell that swallowed it swallowed the
|
|
332
|
+
// duty with it, and nobody has been told anything.
|
|
333
|
+
const promptSent = prompt === undefined ? undefined : tui.running === true;
|
|
334
|
+
|
|
335
|
+
return entry(made, {
|
|
336
|
+
bot: bot.name,
|
|
337
|
+
name: session.name,
|
|
338
|
+
created: true,
|
|
339
|
+
...tui,
|
|
340
|
+
promptSent,
|
|
341
|
+
promptFile,
|
|
342
|
+
resumed: resume !== undefined,
|
|
343
|
+
unclaimed: which.unclaimed,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* One look at a tab the kit just typed a launch line into: whether a harness
|
|
349
|
+
* is running there, and what it is waiting on if Orca says.
|
|
350
|
+
*
|
|
351
|
+
* The harness is up when something other than the shell holds the tab's
|
|
352
|
+
* terminal, busy or not, or when Orca sees a question on its screen. Orca's
|
|
353
|
+
* `agentIdentity` is not asked for: it can come seconds after the launch
|
|
354
|
+
* (tech notes, section 1). When the tab's foreground cannot be read, Orca's own
|
|
355
|
+
* answers stand in, for this report only: nothing more is typed here.
|
|
356
|
+
*/
|
|
357
|
+
function lookFor(handle, timeoutMs) {
|
|
358
|
+
const seen = harnessInTab(handle, timeoutMs);
|
|
359
|
+
const running = seen.blockedReason !== undefined
|
|
360
|
+
|| (seen.front === undefined ? seen.answered || seen.agent !== undefined : seen.front === 'program');
|
|
361
|
+
return { running, blockedReason: seen.blockedReason };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Make sure the book holds this session's mailbox, and — only where this run
|
|
366
|
+
* has just launched the session under it — the name it answers to.
|
|
367
|
+
*
|
|
368
|
+
* The name is written by whoever typed the launch line that gave it, and by
|
|
369
|
+
* nobody else. A session that was already running when the kit reached it was
|
|
370
|
+
* started without `-n`, nothing renames a live harness, and a name in the book
|
|
371
|
+
* that no harness answers to is worse than no name: the kit would send another
|
|
372
|
+
* session to an address with nobody at it.
|
|
373
|
+
*
|
|
374
|
+
* The mailbox is an Orca Run, made once and kept for ever. It has to be a Run
|
|
375
|
+
* rather than the session's tab: Orca calls a terminal a live terminal-only
|
|
376
|
+
* mailbox, says plainly that delivery does not outlive the tab, and refuses a
|
|
377
|
+
* send once the pane is gone, while a Run survives a closed tab, a relaunch and
|
|
378
|
+
* a restart (tech notes, section 1). Orca offers no way to delete one, so this
|
|
379
|
+
* makes exactly one per session and never a second.
|
|
380
|
+
*
|
|
381
|
+
* It is bound to `handle`, the session's own tab, and never to the tab this run
|
|
382
|
+
* of `obk up` was typed in: Orca tells a Run's coordinator about its mail, and
|
|
383
|
+
* only the session the mail is for should be told (issue #228). A mailbox the
|
|
384
|
+
* session already had is bound again when this run `opened` a new tab for it —
|
|
385
|
+
* after a restart, or a closed tab brought back — or it would stay with the tab
|
|
386
|
+
* that is gone (review of PR #248, finding 1).
|
|
387
|
+
*
|
|
388
|
+
* A Codex session whose user turned the sandbox switch off gets none: it could
|
|
389
|
+
* not read a mailbox if it had one, and an address nobody can read is worse
|
|
390
|
+
* than none at all. `obk message` says so in those words.
|
|
391
|
+
*/
|
|
392
|
+
async function ensureMailbox(home, bot, session, harness, handle, { named = false, opened = false } = {}) {
|
|
393
|
+
const held = readBook(home).sessions[session.name] ?? {};
|
|
394
|
+
if (opened && typeof held.mailbox === 'string') useMailbox(held.mailbox, handle);
|
|
395
|
+
|
|
396
|
+
const address = named ? addressOf(bot.name, session.name) : undefined;
|
|
397
|
+
const mailbox = mailboxFor(readBook(home), bot, session, harness, handle);
|
|
398
|
+
|
|
399
|
+
if (mailbox === undefined && (address === undefined || held.address === address)) return;
|
|
400
|
+
|
|
401
|
+
await updateBook(home, (current) => {
|
|
402
|
+
const entry = { ...current.sessions[session.name] };
|
|
403
|
+
// Under the lock, and only if the book still has none: two runs at once
|
|
404
|
+
// would each have made one, and a session with two mailboxes is a session
|
|
405
|
+
// half its mail never reaches. The loser's Run is left unused.
|
|
406
|
+
if (mailbox !== undefined && typeof entry.mailbox !== 'string') entry.mailbox = mailbox;
|
|
407
|
+
if (address !== undefined) entry.address = address;
|
|
408
|
+
current.sessions[session.name] = entry;
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* A new mailbox for this session, or undefined when it should not have one: it
|
|
414
|
+
* has one already, or it is a Codex session whose user turned the sandbox
|
|
415
|
+
* switch off, which could not read a mailbox if it had one.
|
|
416
|
+
*
|
|
417
|
+
* The Orca call is made outside the book's lock, which is held for one read and
|
|
418
|
+
* one write (book.js).
|
|
419
|
+
*/
|
|
420
|
+
function mailboxFor(book, bot, session, harness, handle) {
|
|
421
|
+
const held = book.sessions[session.name] ?? {};
|
|
422
|
+
if (typeof held.mailbox === 'string' || !reachesMail(session, harness)) return undefined;
|
|
423
|
+
return makeMailbox(`${bot.name}/${session.name}`, handle);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Which conversation this session is about to be, as far as anything can say:
|
|
428
|
+
* the one the book holds, one the harness itself still has on record, or none.
|
|
429
|
+
*
|
|
430
|
+
* One tab holds one session, and the book is the authority for which
|
|
431
|
+
* conversation that is (ADR 0012). But the book can be incomplete — on Codex a
|
|
432
|
+
* hooks file must be trusted before any hook runs, and trusting it does not
|
|
433
|
+
* replay the event it missed — and "the book does not say" must never be read as
|
|
434
|
+
* "there was no conversation". So where the book is silent about a tab the kit
|
|
435
|
+
* has already started a harness in, the harness's own record is asked.
|
|
436
|
+
*/
|
|
437
|
+
function whichConversation(book, home, bot, session, was, harness) {
|
|
438
|
+
if (typeof was?.session === 'string') return { resume: was.session };
|
|
439
|
+
// No tab: nothing has ever run for this session, so there is nothing to find.
|
|
440
|
+
if (typeof was?.tab !== 'string') return {};
|
|
441
|
+
|
|
442
|
+
// A harness ran in this tab before and the kit never learned which
|
|
443
|
+
// conversation it became — on Codex, a hooks file trusted too late is enough
|
|
444
|
+
// to do that. What the harness still has in this folder is worth writing down,
|
|
445
|
+
// but it is never this session's by the kit's say-so: the folder holds every
|
|
446
|
+
// session of the bot and everything they started inside themselves, and the
|
|
447
|
+
// review proved both ways that go wrong. So the session starts a new
|
|
448
|
+
// conversation with its duty, and what nobody claims is left on the record for
|
|
449
|
+
// a person or Bot Father to settle.
|
|
450
|
+
const claimed = sessionIdsIn(book);
|
|
451
|
+
const known = typeof was.launched === 'string'
|
|
452
|
+
? conversationsIn(harness, home, was.launched).filter((one) => !claimed.has(one.id))
|
|
453
|
+
: [];
|
|
454
|
+
return { unclaimed: known.map((one) => one.id) };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* A bot's Orca project, made if it is not there yet.
|
|
459
|
+
*
|
|
460
|
+
* It has to be a folder workspace. Registering a folder that sits inside the
|
|
461
|
+
* bots repo gets it recorded as a git one, which Orca gives no workspace at
|
|
462
|
+
* all, so no tab can be opened in it.
|
|
463
|
+
*/
|
|
464
|
+
function orcaProject(home, title) {
|
|
465
|
+
const found = findProject(home);
|
|
466
|
+
const setup = found === undefined
|
|
467
|
+
? makeProject(home, title)
|
|
468
|
+
: (found.kind === 'folder' ? found : asFolderProject(found.id, title));
|
|
469
|
+
return { project: setup.projectId, setup: setup.id };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Where the kit leaves a prompt for the launch line to pick up: a folder of its
|
|
474
|
+
* own beside the bots repo, the way skill-source clones sit beside it (PRD 6.3)
|
|
475
|
+
* — kit-made, never inside the user's repo, so it stays out of their git status.
|
|
476
|
+
*
|
|
477
|
+
* Beside *this* bots folder, and not in a shared temp directory: two bots
|
|
478
|
+
* folders may each hold an api-bot with a daily session, and one file for both
|
|
479
|
+
* of them is one bot's duty handed to another's session.
|
|
480
|
+
*/
|
|
481
|
+
export const promptPath = (bots, bot, session) =>
|
|
482
|
+
path.join(`${bots}.prompts`, `${encodeURIComponent(bot)}.${encodeURIComponent(session)}.txt`);
|
|
483
|
+
|
|
484
|
+
function entry(tab, { bot, name, created, running = false, blockedReason, promptSent, promptFile, resumed, unclaimed }) {
|
|
485
|
+
const made = { bot, name, title: tab.title, tabId: tab.tabId, terminal: tab.handle, created, harnessStarted: running };
|
|
486
|
+
// Whether this run picked the session up where it was or started a new one.
|
|
487
|
+
// Only for a tab this run opened: a tab that was already there was left alone.
|
|
488
|
+
if (resumed !== undefined) made.resumed = resumed;
|
|
489
|
+
// Orca's own words for what is on screen waiting to be answered, when it
|
|
490
|
+
// gave any: the caller acts on it, the kit only passes it on.
|
|
491
|
+
if (blockedReason !== undefined) made.blockedReason = blockedReason;
|
|
492
|
+
// Conversations the harness still has in this bot's folder that no session
|
|
493
|
+
// claims: the caller is told, because the kit will not pick one.
|
|
494
|
+
if (unclaimed !== undefined && unclaimed.length > 0) made.unclaimed = unclaimed;
|
|
495
|
+
// Only for a session this run started that had something to be told.
|
|
496
|
+
if (promptSent !== undefined) made.promptSent = promptSent;
|
|
497
|
+
// And the file it was told it out of, when it was too long for the line.
|
|
498
|
+
if (promptFile !== undefined) made.promptFile = promptFile;
|
|
499
|
+
return made;
|
|
500
|
+
}
|