@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/settings.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// What a running session really runs with, set beside what its bot.yaml asks for.
|
|
2
|
+
//
|
|
3
|
+
// A session can run on settings other than the ones bot.yaml asks for: it
|
|
4
|
+
// started before a change, a default of the user's own leaked in, or a flag was
|
|
5
|
+
// dropped. Each harness writes down what it used (tech notes, sections 2 and 3),
|
|
6
|
+
// so the two can be put side by side. Read only: these are the harness's files.
|
|
7
|
+
//
|
|
8
|
+
// Four answers, and only one of them is good news. A record that cannot be read,
|
|
9
|
+
// or that does not carry the setting, is not agreement: it is `unknown`. A
|
|
10
|
+
// setting the session never asked for is not a disagreement either, whatever the
|
|
11
|
+
// harness chose: it is `not-asked-for`.
|
|
12
|
+
|
|
13
|
+
import { DEFAULT_APPROVAL } from './launch.js';
|
|
14
|
+
import { lines } from './usage.js';
|
|
15
|
+
|
|
16
|
+
export const SETTINGS = ['model', 'effort', 'context', 'approval'];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Each setting of `session` as `{ state, configured?, observed? }`, judged
|
|
20
|
+
* against `file`, the harness's record of the conversation the session is in
|
|
21
|
+
* now, from `since`, when the kit last started it. No file is no record, and
|
|
22
|
+
* every setting asked for is then unknown.
|
|
23
|
+
*/
|
|
24
|
+
export function settingsInUse(harness, session, file, since) {
|
|
25
|
+
const used = file === undefined ? {} : (harness === 'claude' ? fromClaude : fromCodex)(sinceStart(lines(file), since));
|
|
26
|
+
return Object.fromEntries(SETTINGS.map((name) => [name, judged(harness, name, asked(session, name), used[name])]));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* What the record says since the kit last started the session. A start resumes
|
|
31
|
+
* the conversation it had, so the file still holds what the process before it
|
|
32
|
+
* wrote, and that says what that process ran with, not this one. Everything
|
|
33
|
+
* from the first line written at or after the start counts, a line with no time
|
|
34
|
+
* of its own included. With no start to go by, no line can be shown to be this
|
|
35
|
+
* process's, so none counts.
|
|
36
|
+
*/
|
|
37
|
+
function sinceStart(entries, since) {
|
|
38
|
+
const from = Date.parse(since ?? '');
|
|
39
|
+
if (Number.isNaN(from)) return [];
|
|
40
|
+
const first = entries.findIndex((entry) => Date.parse(entry.timestamp ?? '') >= from);
|
|
41
|
+
return first === -1 ? [] : entries.slice(first);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** What the session asks for, as bot.yaml has it, or undefined when it asks for nothing. */
|
|
45
|
+
function asked(session, name) {
|
|
46
|
+
const value = session[name];
|
|
47
|
+
const set = value !== undefined && value !== null && String(value).trim() !== '';
|
|
48
|
+
// The kit puts an approval level on every launch line, so a session always
|
|
49
|
+
// asks for one: the default, when it names none (ADR 0015).
|
|
50
|
+
if (name === 'approval') return set ? String(value).trim() : DEFAULT_APPROVAL;
|
|
51
|
+
return set ? String(value).trim() : undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function judged(harness, name, configured, observed) {
|
|
55
|
+
const shown = observed === undefined ? {} : { observed: said(name, observed) };
|
|
56
|
+
if (configured === undefined) return { state: 'not-asked-for', ...shown };
|
|
57
|
+
const verdict = same(harness, name, configured, observed);
|
|
58
|
+
if (verdict === undefined) return { state: 'unknown', configured, ...shown };
|
|
59
|
+
return { state: verdict ? 'match' : 'mismatch', configured, ...shown };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Whether the record says what was asked for; undefined when it cannot say. */
|
|
63
|
+
function same(harness, name, configured, observed) {
|
|
64
|
+
if (observed === undefined) return undefined;
|
|
65
|
+
if (name === 'model') return sameModel(configured, observed);
|
|
66
|
+
// Codex reports the window it was given at 95%, rounded down (tech notes,
|
|
67
|
+
// section 3): 200000 comes back as 190000.
|
|
68
|
+
if (name === 'context') return Number(observed) === Math.floor(Number(configured) * 0.95);
|
|
69
|
+
if (name === 'approval') return harness === 'claude' ? observed === CLAUDE_MODES[configured] : sameCodexApproval(configured, observed);
|
|
70
|
+
return observed === configured;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A model named without a dash is an alias, `sonnet`, and the harness writes the
|
|
75
|
+
* id it stands for, `claude-sonnet-5`: it matches an id it is one part of. A
|
|
76
|
+
* model with a dash is an id already, and has to be the one that ran. The
|
|
77
|
+
* context window Claude Code carries on the model name, `sonnet[1m]`, is not
|
|
78
|
+
* part of what it writes down.
|
|
79
|
+
*/
|
|
80
|
+
function sameModel(configured, observed) {
|
|
81
|
+
const model = configured.replace(/\[[^\]]*\]$/, '');
|
|
82
|
+
return model.includes('-') ? observed === model : observed.split('-').includes(model);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The permission mode Claude Code records for each level (tech notes, section 2). */
|
|
86
|
+
const CLAUDE_MODES = { auto: 'auto', ask: 'default', 'dangerously-skip': 'bypassPermissions' };
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* What Codex records for each level, and only the words that level decides: at
|
|
90
|
+
* `ask` the kit passes only the approval policy, so the sandbox is the user's
|
|
91
|
+
* own (ADR 0015), and at `dangerously-skip` nothing is reviewed (tech notes,
|
|
92
|
+
* section 3).
|
|
93
|
+
*/
|
|
94
|
+
const CODEX_WORDS = {
|
|
95
|
+
auto: { policy: 'on-request', reviewer: 'auto_review', sandbox: 'workspace-write' },
|
|
96
|
+
ask: { policy: 'on-request', reviewer: 'user' },
|
|
97
|
+
'dangerously-skip': { policy: 'never', sandbox: 'danger-full-access' },
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
function sameCodexApproval(configured, observed) {
|
|
101
|
+
const wanted = CODEX_WORDS[configured];
|
|
102
|
+
if (wanted === undefined) return false;
|
|
103
|
+
const pairs = Object.entries(wanted);
|
|
104
|
+
// A word the level decides and the record does not carry leaves it unsaid.
|
|
105
|
+
if (pairs.some(([key]) => observed[key] === undefined)) return undefined;
|
|
106
|
+
return pairs.every(([key, word]) => observed[key] === word);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A value the record carried, as the report shows it. */
|
|
110
|
+
function said(name, observed) {
|
|
111
|
+
if (name !== 'approval' || typeof observed === 'string') return String(observed);
|
|
112
|
+
return [
|
|
113
|
+
`approval_policy ${observed.policy}`,
|
|
114
|
+
...(observed.reviewer === undefined ? [] : [`approvals_reviewer ${observed.reviewer}`]),
|
|
115
|
+
...(observed.sandbox === undefined ? [] : [`sandbox ${observed.sandbox}`]),
|
|
116
|
+
].join(', ');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* What a Claude Code transcript says it runs with, the latest of each: the
|
|
121
|
+
* model and effort on its replies, the permission mode wherever it is noted.
|
|
122
|
+
* Claude Code writes nothing that shows the context window.
|
|
123
|
+
*/
|
|
124
|
+
function fromClaude(entries) {
|
|
125
|
+
const used = {};
|
|
126
|
+
for (const entry of entries) {
|
|
127
|
+
if (entry.type === 'assistant' && entry.isSidechain !== true) {
|
|
128
|
+
const model = entry.message?.model;
|
|
129
|
+
if (typeof model === 'string' && model !== '<synthetic>') used.model = model;
|
|
130
|
+
if (typeof entry.effort === 'string') used.effort = entry.effort;
|
|
131
|
+
}
|
|
132
|
+
if (typeof entry.permissionMode === 'string') used.approval = entry.permissionMode;
|
|
133
|
+
}
|
|
134
|
+
return used;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** What a Codex rollout says it runs with: its latest turn, and its latest count. */
|
|
138
|
+
function fromCodex(entries) {
|
|
139
|
+
const used = {};
|
|
140
|
+
for (const entry of entries) {
|
|
141
|
+
const payload = entry.payload;
|
|
142
|
+
if (payload === null || typeof payload !== 'object') continue;
|
|
143
|
+
if (entry.type === 'turn_context') {
|
|
144
|
+
if (typeof payload.model === 'string') used.model = payload.model;
|
|
145
|
+
if (typeof payload.effort === 'string') used.effort = payload.effort;
|
|
146
|
+
if (typeof payload.approval_policy === 'string') {
|
|
147
|
+
used.approval = {
|
|
148
|
+
policy: payload.approval_policy,
|
|
149
|
+
reviewer: typeof payload.approvals_reviewer === 'string' ? payload.approvals_reviewer : undefined,
|
|
150
|
+
sandbox: typeof payload.sandbox_policy?.type === 'string' ? payload.sandbox_policy.type : undefined,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const window = payload.info?.model_context_window;
|
|
155
|
+
if (entry.type === 'event_msg' && payload.type === 'token_count' && typeof window === 'number') used.context = window;
|
|
156
|
+
}
|
|
157
|
+
return used;
|
|
158
|
+
}
|
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
|
@@ -6,14 +6,15 @@
|
|
|
6
6
|
|
|
7
7
|
import { existsSync, mkdirSync, realpathSync, writeFileSync } from 'node:fs';
|
|
8
8
|
import path from 'node:path';
|
|
9
|
+
import { setTimeout as pause } from 'node:timers/promises';
|
|
9
10
|
|
|
10
11
|
import { forgetClaimed, readBook, sessionIdsIn, tabIdsIn, updateBook, withUnclaimed } from './book.js';
|
|
11
12
|
import { botDir, botNames, displayName, readBot } from './bot.js';
|
|
12
|
-
import { conversationsIn } from './conversations.js';
|
|
13
|
+
import { conversationsIn, heldAsUserTurn, transcriptsIn } from './conversations.js';
|
|
13
14
|
import { installHook } from './hooks.js';
|
|
14
15
|
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';
|
|
16
|
+
import { asFolderProject, findProject, harnessInTab, makeMailbox, makeProject, openTab, retitleTab, tabs, tellWindow, typeIntoTab, useMailbox } from './orca.js';
|
|
17
|
+
import { buildAgents, rulesStamp } from './rules.js';
|
|
17
18
|
import { linkSkills } from './skills.js';
|
|
18
19
|
|
|
19
20
|
/** The one bot with a tab beside its sessions: the ops tab (PRD 6.2). */
|
|
@@ -35,6 +36,7 @@ const SECOND_LOOK_MS = 2000;
|
|
|
35
36
|
* Bring bots up in Orca. Returns `{ tabs, rules }`: one tab entry per tab it
|
|
36
37
|
* looked at — the sessions the book knows, and, for Bot Father, whatever else
|
|
37
38
|
* is open in its project — and one rules entry and one skills entry per bot.
|
|
39
|
+
* `projects` has one entry per Orca project this run made or renamed.
|
|
38
40
|
*
|
|
39
41
|
* With no name it is every bot in the folder, in name order. `bot` brings up
|
|
40
42
|
* one bot and `session` one of its sessions, for a caller that wants one thing
|
|
@@ -58,11 +60,14 @@ export async function bringUp(bots, { bot: onlyBot, session: onlySession } = {})
|
|
|
58
60
|
const { running, rules, skills } = prepareBots(bots, names, onlySession);
|
|
59
61
|
|
|
60
62
|
const report = [];
|
|
63
|
+
const projects = [];
|
|
61
64
|
for (const { bot, home } of running) {
|
|
62
65
|
for (const session of sessionsOf(bot, onlySession)) {
|
|
63
66
|
if (session.paused === true) paused.push({ bot: bot.name, session: session.name });
|
|
64
67
|
}
|
|
65
|
-
|
|
68
|
+
const up = await bringUpBot(bots, home, bot, onlySession);
|
|
69
|
+
report.push(...up.tabs);
|
|
70
|
+
if (up.project !== undefined) projects.push(up.project);
|
|
66
71
|
}
|
|
67
72
|
|
|
68
73
|
// A bot whose rules would not build is reported here rather than in the
|
|
@@ -78,6 +83,7 @@ export async function bringUp(bots, { bot: onlyBot, session: onlySession } = {})
|
|
|
78
83
|
})),
|
|
79
84
|
skills,
|
|
80
85
|
paused,
|
|
86
|
+
projects,
|
|
81
87
|
};
|
|
82
88
|
}
|
|
83
89
|
|
|
@@ -197,8 +203,11 @@ async function bringUpBot(bots, home, bot, onlySession) {
|
|
|
197
203
|
// Orca is asked first and the book is written after: nothing that takes time
|
|
198
204
|
// happens while the book is held, because a session's own hook may be writing
|
|
199
205
|
// its id into that same file at any moment.
|
|
200
|
-
const orca = orcaProject(home, title);
|
|
206
|
+
const { change, ...orca } = orcaProject(home, title);
|
|
201
207
|
await updateBook(home, (book) => { book.orca = orca; });
|
|
208
|
+
// Orca's window does not see a project made or renamed on the command line
|
|
209
|
+
// until it is told (#224).
|
|
210
|
+
if (change !== undefined) tellWindow(orca.project);
|
|
202
211
|
|
|
203
212
|
const live = new Map(tabs(home).map((tab) => [tab.tabId, tab]));
|
|
204
213
|
const report = [];
|
|
@@ -217,7 +226,7 @@ async function bringUpBot(bots, home, bot, onlySession) {
|
|
|
217
226
|
: spare.map((tab) => entry(tab, { bot: name, name: null, created: false }))));
|
|
218
227
|
}
|
|
219
228
|
|
|
220
|
-
return report;
|
|
229
|
+
return { tabs: report, project: change === undefined ? undefined : { bot: name, project: orca.project, change } };
|
|
221
230
|
}
|
|
222
231
|
|
|
223
232
|
async function bringUpSession(bots, home, live, session, bot, title) {
|
|
@@ -292,12 +301,17 @@ async function bringUpSession(bots, home, live, session, bot, title) {
|
|
|
292
301
|
// started a harness in it, and a conversation taken over from the harness's
|
|
293
302
|
// own record are new.
|
|
294
303
|
const launched = new Date().toISOString();
|
|
304
|
+
// Which instructions it is about to read: the AGENTS.md this run has just
|
|
305
|
+
// built, noted so that health can say when the file moves on and the session
|
|
306
|
+
// does not (#272).
|
|
307
|
+
const rules = rulesStamp(home);
|
|
295
308
|
await updateBook(home, (current) => {
|
|
296
309
|
// What the harness has in this folder that nobody claims goes on the record,
|
|
297
310
|
// for a person or Bot Father to settle — added to whatever was already noted,
|
|
298
311
|
// because this run's scan cannot see what an earlier one found. The kit never
|
|
299
312
|
// settles it itself.
|
|
300
|
-
const entry = { ...current.sessions[session.name], tab: made.tabId, launched };
|
|
313
|
+
const entry = { ...current.sessions[session.name], tab: made.tabId, launched, rules };
|
|
314
|
+
if (rules === undefined) delete entry.rules;
|
|
301
315
|
current.sessions[session.name] = withUnclaimed(entry, which.unclaimed ?? []);
|
|
302
316
|
forgetClaimed(current);
|
|
303
317
|
});
|
|
@@ -326,24 +340,54 @@ async function bringUpSession(bots, home, live, session, bot, title) {
|
|
|
326
340
|
const tui = first.running ? lookFor(made.handle, SECOND_LOOK_MS) : first;
|
|
327
341
|
|
|
328
342
|
// The start prompt went in with that line, as the harness's own prompt
|
|
329
|
-
// argument,
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
const
|
|
343
|
+
// argument, and a harness running in the tab is not yet a session told its
|
|
344
|
+
// duty: one held on a first-run screen, or one that refused the argument, has
|
|
345
|
+
// been told nothing. So it is received only when the session's own record
|
|
346
|
+
// holds it as a user turn, and otherwise not confirmed (#274).
|
|
347
|
+
const promptReceived = prompt === undefined
|
|
348
|
+
? undefined
|
|
349
|
+
: await heldInRecord(home, session.name, harness, prompt, { launched, running: tui.running === true });
|
|
334
350
|
|
|
335
351
|
return entry(made, {
|
|
336
352
|
bot: bot.name,
|
|
337
353
|
name: session.name,
|
|
338
354
|
created: true,
|
|
339
355
|
...tui,
|
|
340
|
-
|
|
356
|
+
promptReceived,
|
|
341
357
|
promptFile,
|
|
342
358
|
resumed: resume !== undefined,
|
|
343
359
|
unclaimed: which.unclaimed,
|
|
344
360
|
});
|
|
345
361
|
}
|
|
346
362
|
|
|
363
|
+
/**
|
|
364
|
+
* How long a running harness's own record is given to show the start prompt.
|
|
365
|
+
* Seen live (Claude Code 2.1.282, a trusted folder): the hook's id and the
|
|
366
|
+
* user turn landed about as the second look ended, so one read there comes a
|
|
367
|
+
* moment too early. A harness held on a first-run screen costs the whole wait.
|
|
368
|
+
*/
|
|
369
|
+
const RECORD_MS = 5000;
|
|
370
|
+
const RECORD_ASK_MS = 250;
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Whether the conversation the book names for this session holds `prompt` as a
|
|
374
|
+
* user turn in the harness's own record. The name comes from the session's own
|
|
375
|
+
* hook, so a harness that has not reported one — held on a first-run screen,
|
|
376
|
+
* say — has no record to be read, and the answer is that it is not confirmed.
|
|
377
|
+
* Another conversation in the same folder is never asked: it may be a sister
|
|
378
|
+
* session told the same words.
|
|
379
|
+
*/
|
|
380
|
+
async function heldInRecord(home, name, harness, prompt, { launched, running }) {
|
|
381
|
+
const until = Date.now() + (running ? RECORD_MS : 0);
|
|
382
|
+
for (;;) {
|
|
383
|
+
const id = readBook(home).sessions[name]?.session;
|
|
384
|
+
const record = typeof id === 'string' ? transcriptsIn(harness, home, launched).find((one) => one.id === id) : undefined;
|
|
385
|
+
if (record !== undefined && heldAsUserTurn(harness, record.file, prompt)) return true;
|
|
386
|
+
if (Date.now() >= until) return false;
|
|
387
|
+
await pause(RECORD_ASK_MS);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
347
391
|
/**
|
|
348
392
|
* One look at a tab the kit just typed a launch line into: whether a harness
|
|
349
393
|
* is running there, and what it is waiting on if Orca says.
|
|
@@ -455,7 +499,8 @@ function whichConversation(book, home, bot, session, was, harness) {
|
|
|
455
499
|
}
|
|
456
500
|
|
|
457
501
|
/**
|
|
458
|
-
* A bot's Orca project, made if it is not there yet
|
|
502
|
+
* A bot's Orca project, made if it is not there yet, with `change` saying
|
|
503
|
+
* whether this run `made` it, `renamed` it or left it as it was (undefined).
|
|
459
504
|
*
|
|
460
505
|
* It has to be a folder workspace. Registering a folder that sits inside the
|
|
461
506
|
* bots repo gets it recorded as a git one, which Orca gives no workspace at
|
|
@@ -463,12 +508,12 @@ function whichConversation(book, home, bot, session, was, harness) {
|
|
|
463
508
|
*/
|
|
464
509
|
function orcaProject(home, title) {
|
|
465
510
|
const found = findProject(home);
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
: (found.kind === 'folder' ? found : asFolderProject(found.id, title));
|
|
469
|
-
return { project: setup.projectId, setup: setup.id };
|
|
511
|
+
if (found === undefined) return ids(makeProject(home, title), 'made');
|
|
512
|
+
return found.kind === 'folder' ? ids(found) : ids(asFolderProject(found.id, title), 'renamed');
|
|
470
513
|
}
|
|
471
514
|
|
|
515
|
+
const ids = (setup, change) => ({ project: setup.projectId, setup: setup.id, change });
|
|
516
|
+
|
|
472
517
|
/**
|
|
473
518
|
* Where the kit leaves a prompt for the launch line to pick up: a folder of its
|
|
474
519
|
* own beside the bots repo, the way skill-source clones sit beside it (PRD 6.3)
|
|
@@ -481,7 +526,7 @@ function orcaProject(home, title) {
|
|
|
481
526
|
export const promptPath = (bots, bot, session) =>
|
|
482
527
|
path.join(`${bots}.prompts`, `${encodeURIComponent(bot)}.${encodeURIComponent(session)}.txt`);
|
|
483
528
|
|
|
484
|
-
function entry(tab, { bot, name, created, running = false, blockedReason,
|
|
529
|
+
function entry(tab, { bot, name, created, running = false, blockedReason, promptReceived, promptFile, resumed, unclaimed }) {
|
|
485
530
|
const made = { bot, name, title: tab.title, tabId: tab.tabId, terminal: tab.handle, created, harnessStarted: running };
|
|
486
531
|
// Whether this run picked the session up where it was or started a new one.
|
|
487
532
|
// Only for a tab this run opened: a tab that was already there was left alone.
|
|
@@ -493,7 +538,7 @@ function entry(tab, { bot, name, created, running = false, blockedReason, prompt
|
|
|
493
538
|
// claims: the caller is told, because the kit will not pick one.
|
|
494
539
|
if (unclaimed !== undefined && unclaimed.length > 0) made.unclaimed = unclaimed;
|
|
495
540
|
// Only for a session this run started that had something to be told.
|
|
496
|
-
if (
|
|
541
|
+
if (promptReceived !== undefined) made.promptReceived = promptReceived;
|
|
497
542
|
// And the file it was told it out of, when it was too long for the line.
|
|
498
543
|
if (promptFile !== undefined) made.promptFile = promptFile;
|
|
499
544
|
return made;
|