@assuredloop/orca-bot-kit 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -0
- package/package.json +1 -1
- package/skills/obk-bot-building/SKILL.md +4 -1
- package/src/book.js +13 -0
- package/src/cli.js +61 -12
- package/src/conversations.js +73 -5
- package/src/health.js +124 -23
- package/src/message.js +28 -6
- package/src/orca.js +24 -3
- package/src/record.js +8 -1
- package/src/rules.js +15 -0
- package/src/settings.js +158 -0
- package/src/up.js +119 -39
- package/src/usage.js +147 -52
package/src/orca.js
CHANGED
|
@@ -30,11 +30,17 @@ export function orca(args) {
|
|
|
30
30
|
if (answer.ok !== true) {
|
|
31
31
|
// Orca's own words: it knows what went wrong, and the caller is an LLM
|
|
32
32
|
// that can act on them.
|
|
33
|
-
throw
|
|
33
|
+
throw refusal(args, answer);
|
|
34
34
|
}
|
|
35
35
|
return answer.result;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/** Orca's refusal as an error, with its code on it, so a caller can tell a stale handle from the rest (#294). */
|
|
39
|
+
const refusal = (args, answer) => Object.assign(
|
|
40
|
+
new Error(`Orca refused ${args.join(' ')}: ${answer.error?.message ?? 'no reason given'}`),
|
|
41
|
+
{ code: answer.error?.code },
|
|
42
|
+
);
|
|
43
|
+
|
|
38
44
|
/** One Orca command, and the whole envelope back, refusals included. */
|
|
39
45
|
function ask(args) {
|
|
40
46
|
const asked = spawnSync(orcaCli(), [...args, '--json'], { encoding: 'utf8' });
|
|
@@ -257,7 +263,7 @@ export function harnessInTab(handle, timeoutMs) {
|
|
|
257
263
|
// Out of time means nothing went idle, busy or absent alike. That is an
|
|
258
264
|
// answer, not a breakdown.
|
|
259
265
|
if (answer.ok !== true && answer.error?.code !== 'timeout') {
|
|
260
|
-
throw
|
|
266
|
+
throw refusal(args, answer);
|
|
261
267
|
}
|
|
262
268
|
|
|
263
269
|
const shown = orca(['terminal', 'show', '--terminal', handle]).terminal;
|
|
@@ -312,6 +318,21 @@ export function tabToTypeInto(home, tabId, timeoutMs) {
|
|
|
312
318
|
return { handle: live.handle, agent: seen.agent };
|
|
313
319
|
}
|
|
314
320
|
|
|
321
|
+
/**
|
|
322
|
+
* Who holds the terminal of the tab `handle`, as `frontOf` answers, without
|
|
323
|
+
* waiting on the tab: for a caller that asks about every tab and has nothing
|
|
324
|
+
* to type. Orca refusing is an answer that cannot be read, not an error.
|
|
325
|
+
*/
|
|
326
|
+
export function frontOfTab(handle) {
|
|
327
|
+
let shown;
|
|
328
|
+
try {
|
|
329
|
+
shown = orca(['terminal', 'show', '--terminal', handle]).terminal;
|
|
330
|
+
} catch (error) {
|
|
331
|
+
return { unreadable: `Orca would not show the tab: ${error.message}` };
|
|
332
|
+
}
|
|
333
|
+
return frontOf(shown?.ptyId);
|
|
334
|
+
}
|
|
335
|
+
|
|
315
336
|
/** The `ps` this run reads. OBK_PS overrides it, as OBK_ORCA does Orca. */
|
|
316
337
|
const psCli = () => process.env.OBK_PS || '/bin/ps';
|
|
317
338
|
|
|
@@ -446,7 +467,7 @@ export function postMessage({ to, from, subject, body, type = 'status', thread }
|
|
|
446
467
|
export const readMailbox = (id, { peek = false, handle } = {}) =>
|
|
447
468
|
orca(['orchestration', 'check', '--run', id, ...as(handle), ...(peek ? ['--peek'] : [])]);
|
|
448
469
|
|
|
449
|
-
/** Say a batch has been read
|
|
470
|
+
/** Say a batch has been read. Orca answers with the batch after it. */
|
|
450
471
|
export const ackMailbox = (id, delivery, handle) =>
|
|
451
472
|
orca(['orchestration', 'check', '--run', id, ...as(handle), '--ack', delivery]);
|
|
452
473
|
|
package/src/record.js
CHANGED
|
@@ -17,6 +17,7 @@ import { forgetClaimed, readBook, rememberSession, sessionIdsIn, updateBook, wit
|
|
|
17
17
|
import { botDir, readBot } from './bot.js';
|
|
18
18
|
import { conversationsIn } from './conversations.js';
|
|
19
19
|
import { harnessOf, SHELL_ENV, startPrompt, workDirOf } from './launch.js';
|
|
20
|
+
import { rulesStamp } from './rules.js';
|
|
20
21
|
|
|
21
22
|
/** Where Orca says which tab a program is running in (tech notes, section 1). */
|
|
22
23
|
export const TAB_ENV = 'ORCA_TAB_ID';
|
|
@@ -79,6 +80,11 @@ export async function recordSession(bots, name, said, tabId, shellPid) {
|
|
|
79
80
|
// this bot's folder, which every session of the bot shares — so it can say
|
|
80
81
|
// that a conversation nobody claims exists, and never whose it is.
|
|
81
82
|
const unclaimed = unclaimedFor(readBook(home), home, bot, tabId, id);
|
|
83
|
+
// A clear on Claude Code reads the bot's instructions again, as a start does
|
|
84
|
+
// (tech notes, section 2), so the book notes which ones it read. Codex has no
|
|
85
|
+
// word for its clear, and whether its new conversation reads them again is
|
|
86
|
+
// not established, so nothing is noted for it.
|
|
87
|
+
const rules = said.source === CLEARED ? rulesStamp(home) : undefined;
|
|
82
88
|
|
|
83
89
|
let cleared = false;
|
|
84
90
|
let told;
|
|
@@ -100,7 +106,8 @@ export async function recordSession(bots, name, said, tabId, shellPid) {
|
|
|
100
106
|
const uncertain = was.session === undefined && unclaimed.length > 0;
|
|
101
107
|
told = session;
|
|
102
108
|
|
|
103
|
-
|
|
109
|
+
const now = rememberSession(was, id, said.source);
|
|
110
|
+
book.sessions[session] = withUnclaimed(rules === undefined ? now : { ...now, rules }, unclaimed);
|
|
104
111
|
forgetClaimed(book);
|
|
105
112
|
if (uncertain) cleared = true;
|
|
106
113
|
return book;
|
package/src/rules.js
CHANGED
|
@@ -406,3 +406,18 @@ function sameFile(one, other) {
|
|
|
406
406
|
return false;
|
|
407
407
|
}
|
|
408
408
|
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* A stamp of a bot's `AGENTS.md` as it is now, or undefined when there is none
|
|
412
|
+
* to read. It is what a session read when it started, noted in the book at that
|
|
413
|
+
* moment, so that health can tell a session running on older rules from one on
|
|
414
|
+
* the current ones. The whole file, not only the build's block: what the user
|
|
415
|
+
* wrote below the end marker is part of what the session reads.
|
|
416
|
+
*/
|
|
417
|
+
export function rulesStamp(home) {
|
|
418
|
+
try {
|
|
419
|
+
return checksum(readFileSync(path.join(home, AGENTS), 'utf8'));
|
|
420
|
+
} catch {
|
|
421
|
+
return undefined;
|
|
422
|
+
}
|
|
423
|
+
}
|
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/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
|
-
import { forgetClaimed, readBook, sessionIdsIn, tabIdsIn, updateBook, withUnclaimed } from './book.js';
|
|
11
|
+
import { forgetClaimed, forgetSession, 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, hasConversation, 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
16
|
import { asFolderProject, findProject, harnessInTab, makeMailbox, makeProject, openTab, retitleTab, tabs, tellWindow, typeIntoTab, useMailbox } from './orca.js';
|
|
16
|
-
import { buildAgents } from './rules.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). */
|
|
@@ -262,33 +263,14 @@ async function bringUpSession(bots, home, live, session, bot, title) {
|
|
|
262
263
|
// given again (PRD 6.4) — after a clear it is, and that is the hook's work.
|
|
263
264
|
// Asked before the book is held, because it reads a folder of the harness's
|
|
264
265
|
// own files: nothing slow happens under the lock.
|
|
265
|
-
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
// Anything longer than a line goes to the harness out of a file, rather than
|
|
269
|
-
// through the tab's shell a character at a time.
|
|
270
|
-
const promptFile = prompt === undefined || isShortPrompt(prompt) ? undefined : promptPath(bots, bot.name, session.name);
|
|
271
|
-
const command = launchCommand(session, {
|
|
272
|
-
harness,
|
|
273
|
-
home,
|
|
274
|
-
workDir,
|
|
275
|
-
prompt,
|
|
276
|
-
promptFile,
|
|
277
|
-
resume,
|
|
278
|
-
address: harness === 'claude' ? addressOf(bot.name, session.name) : undefined,
|
|
279
|
-
});
|
|
266
|
+
let which = whichConversation(book, home, bot, session, was, harness);
|
|
267
|
+
const launchOf = (chosen) => launchFor(bots, bot, session, harness, home, workDir, chosen.resume);
|
|
268
|
+
let launch = launchOf(which);
|
|
280
269
|
|
|
281
270
|
// A work dir is a plain folder, made for the session before it is told about
|
|
282
271
|
// it (PRD 6.4). Nothing here is a git worktree.
|
|
283
272
|
if (workDir !== undefined) mkdirSync(workDir, { recursive: true });
|
|
284
|
-
|
|
285
|
-
// The prompt is written where the launch line can read it from, before that
|
|
286
|
-
// line is typed. It is the kit's own file, not the user's: theirs stays where
|
|
287
|
-
// they put it, in the bot home.
|
|
288
|
-
if (promptFile !== undefined) {
|
|
289
|
-
mkdirSync(path.dirname(promptFile), { recursive: true });
|
|
290
|
-
writeFileSync(promptFile, prompt);
|
|
291
|
-
}
|
|
273
|
+
writePrompt(launch);
|
|
292
274
|
|
|
293
275
|
const made = openTab(home, tabTitle);
|
|
294
276
|
|
|
@@ -300,19 +282,45 @@ async function bringUpSession(bots, home, live, session, bot, title) {
|
|
|
300
282
|
// started a harness in it, and a conversation taken over from the harness's
|
|
301
283
|
// own record are new.
|
|
302
284
|
const launched = new Date().toISOString();
|
|
285
|
+
// Which instructions it is about to read: the AGENTS.md this run has just
|
|
286
|
+
// built, noted so that health can say when the file moves on and the session
|
|
287
|
+
// does not (#272).
|
|
288
|
+
const rules = rulesStamp(home);
|
|
289
|
+
let held;
|
|
303
290
|
await updateBook(home, (current) => {
|
|
304
291
|
// What the harness has in this folder that nobody claims goes on the record,
|
|
305
292
|
// for a person or Bot Father to settle — added to whatever was already noted,
|
|
306
293
|
// because this run's scan cannot see what an earlier one found. The kit never
|
|
307
294
|
// settles it itself.
|
|
308
|
-
|
|
295
|
+
let entry = { ...current.sessions[session.name], tab: made.tabId, launched, rules };
|
|
296
|
+
if (rules === undefined) delete entry.rules;
|
|
297
|
+
held = entry.session;
|
|
298
|
+
// Before the line is typed, so the hook finds no id here and takes the one
|
|
299
|
+
// it reports for a start rather than a clear, which would tell the duty twice.
|
|
300
|
+
if (which.noConversation !== undefined && held === which.noConversation) entry = forgetSession(entry, 'no conversation');
|
|
309
301
|
current.sessions[session.name] = withUnclaimed(entry, which.unclaimed ?? []);
|
|
310
302
|
forgetClaimed(current);
|
|
311
303
|
});
|
|
312
304
|
|
|
305
|
+
// The old tab's hook can name another conversation while the tab is being
|
|
306
|
+
// opened. A fresh start chosen before that is chosen again from the id the
|
|
307
|
+
// book holds now: resumed if the harness has it, and otherwise set aside the
|
|
308
|
+
// same way (review of PR #310).
|
|
309
|
+
if (which.noConversation !== undefined && typeof held === 'string' && held !== which.noConversation) {
|
|
310
|
+
which = hasConversation(harness, home, held) ? { resume: held } : { noConversation: held };
|
|
311
|
+
if (which.noConversation !== undefined) {
|
|
312
|
+
await updateBook(home, (current) => {
|
|
313
|
+
const entry = current.sessions[session.name];
|
|
314
|
+
if (entry?.session === held) current.sessions[session.name] = forgetSession(entry, 'no conversation');
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
launch = launchOf(which);
|
|
318
|
+
writePrompt(launch);
|
|
319
|
+
}
|
|
320
|
+
|
|
313
321
|
// Typing it in is the way: for a project the kit has just made, giving Orca
|
|
314
322
|
// the harness as the tab's own command times out and leaves a dead tab.
|
|
315
|
-
typeIntoTab(made.handle, command);
|
|
323
|
+
typeIntoTab(made.handle, launch.command);
|
|
316
324
|
|
|
317
325
|
// Now the session has an address, and not before: the mailbox it can be
|
|
318
326
|
// written to, and — on Claude Code — the name that line just gave it, which
|
|
@@ -334,24 +342,87 @@ async function bringUpSession(bots, home, live, session, bot, title) {
|
|
|
334
342
|
const tui = first.running ? lookFor(made.handle, SECOND_LOOK_MS) : first;
|
|
335
343
|
|
|
336
344
|
// The start prompt went in with that line, as the harness's own prompt
|
|
337
|
-
// argument,
|
|
338
|
-
//
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
const
|
|
345
|
+
// argument, and a harness running in the tab is not yet a session told its
|
|
346
|
+
// duty: one held on a first-run screen, or one that refused the argument, has
|
|
347
|
+
// been told nothing. So it is received only when the session's own record
|
|
348
|
+
// holds it as a user turn, and otherwise not confirmed (#274).
|
|
349
|
+
const promptReceived = launch.prompt === undefined
|
|
350
|
+
? undefined
|
|
351
|
+
: await heldInRecord(home, session.name, harness, launch.prompt, { launched, running: tui.running === true });
|
|
342
352
|
|
|
343
353
|
return entry(made, {
|
|
344
354
|
bot: bot.name,
|
|
345
355
|
name: session.name,
|
|
346
356
|
created: true,
|
|
347
357
|
...tui,
|
|
348
|
-
|
|
349
|
-
promptFile,
|
|
350
|
-
resumed: resume !== undefined,
|
|
358
|
+
promptReceived,
|
|
359
|
+
promptFile: launch.promptFile,
|
|
360
|
+
resumed: launch.resume !== undefined,
|
|
361
|
+
noConversation: which.noConversation,
|
|
351
362
|
unclaimed: which.unclaimed,
|
|
352
363
|
});
|
|
353
364
|
}
|
|
354
365
|
|
|
366
|
+
/**
|
|
367
|
+
* The line that starts this session: resuming `resume`, or, with none, a fresh
|
|
368
|
+
* start carrying its duty. Returns `{ resume, prompt, promptFile, command }`.
|
|
369
|
+
*/
|
|
370
|
+
function launchFor(bots, bot, session, harness, home, workDir, resume) {
|
|
371
|
+
const prompt = resume === undefined ? startPrompt(session, { home, workDir }) : undefined;
|
|
372
|
+
// Anything longer than a line goes to the harness out of a file, rather than
|
|
373
|
+
// through the tab's shell a character at a time.
|
|
374
|
+
const promptFile = prompt === undefined || isShortPrompt(prompt) ? undefined : promptPath(bots, bot.name, session.name);
|
|
375
|
+
const command = launchCommand(session, {
|
|
376
|
+
harness,
|
|
377
|
+
home,
|
|
378
|
+
workDir,
|
|
379
|
+
prompt,
|
|
380
|
+
promptFile,
|
|
381
|
+
resume,
|
|
382
|
+
address: harness === 'claude' ? addressOf(bot.name, session.name) : undefined,
|
|
383
|
+
});
|
|
384
|
+
return { resume, prompt, promptFile, command };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* The prompt is written where the launch line can read it from, before that
|
|
389
|
+
* line is typed. It is the kit's own file, not the user's: theirs stays where
|
|
390
|
+
* they put it, in the bot home.
|
|
391
|
+
*/
|
|
392
|
+
function writePrompt({ prompt, promptFile }) {
|
|
393
|
+
if (promptFile === undefined) return;
|
|
394
|
+
mkdirSync(path.dirname(promptFile), { recursive: true });
|
|
395
|
+
writeFileSync(promptFile, prompt);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* How long a running harness's own record is given to show the start prompt.
|
|
400
|
+
* Seen live (Claude Code 2.1.282, a trusted folder): the hook's id and the
|
|
401
|
+
* user turn landed about as the second look ended, so one read there comes a
|
|
402
|
+
* moment too early. A harness held on a first-run screen costs the whole wait.
|
|
403
|
+
*/
|
|
404
|
+
const RECORD_MS = 5000;
|
|
405
|
+
const RECORD_ASK_MS = 250;
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Whether the conversation the book names for this session holds `prompt` as a
|
|
409
|
+
* user turn in the harness's own record. The name comes from the session's own
|
|
410
|
+
* hook, so a harness that has not reported one — held on a first-run screen,
|
|
411
|
+
* say — has no record to be read, and the answer is that it is not confirmed.
|
|
412
|
+
* Another conversation in the same folder is never asked: it may be a sister
|
|
413
|
+
* session told the same words.
|
|
414
|
+
*/
|
|
415
|
+
async function heldInRecord(home, name, harness, prompt, { launched, running }) {
|
|
416
|
+
const until = Date.now() + (running ? RECORD_MS : 0);
|
|
417
|
+
for (;;) {
|
|
418
|
+
const id = readBook(home).sessions[name]?.session;
|
|
419
|
+
const record = typeof id === 'string' ? transcriptsIn(harness, home, launched).find((one) => one.id === id) : undefined;
|
|
420
|
+
if (record !== undefined && heldAsUserTurn(harness, record.file, prompt)) return true;
|
|
421
|
+
if (Date.now() >= until) return false;
|
|
422
|
+
await pause(RECORD_ASK_MS);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
355
426
|
/**
|
|
356
427
|
* One look at a tab the kit just typed a launch line into: whether a harness
|
|
357
428
|
* is running there, and what it is waiting on if Orca says.
|
|
@@ -443,7 +514,14 @@ function mailboxFor(book, bot, session, harness, handle) {
|
|
|
443
514
|
* has already started a harness in, the harness's own record is asked.
|
|
444
515
|
*/
|
|
445
516
|
function whichConversation(book, home, bot, session, was, harness) {
|
|
446
|
-
if (typeof was?.session === 'string')
|
|
517
|
+
if (typeof was?.session === 'string') {
|
|
518
|
+
if (hasConversation(harness, home, was.session)) return { resume: was.session };
|
|
519
|
+
// The hook reported an id and the harness never wrote a conversation behind
|
|
520
|
+
// it: a session paused before its first turn is one. There is nothing to
|
|
521
|
+
// resume, so it starts again with its duty, and the book keeps the id in the
|
|
522
|
+
// history with the reason (#295).
|
|
523
|
+
return { noConversation: was.session };
|
|
524
|
+
}
|
|
447
525
|
// No tab: nothing has ever run for this session, so there is nothing to find.
|
|
448
526
|
if (typeof was?.tab !== 'string') return {};
|
|
449
527
|
|
|
@@ -490,11 +568,13 @@ const ids = (setup, change) => ({ project: setup.projectId, setup: setup.id, cha
|
|
|
490
568
|
export const promptPath = (bots, bot, session) =>
|
|
491
569
|
path.join(`${bots}.prompts`, `${encodeURIComponent(bot)}.${encodeURIComponent(session)}.txt`);
|
|
492
570
|
|
|
493
|
-
function entry(tab, { bot, name, created, running = false, blockedReason,
|
|
571
|
+
function entry(tab, { bot, name, created, running = false, blockedReason, promptReceived, promptFile, resumed, noConversation, unclaimed }) {
|
|
494
572
|
const made = { bot, name, title: tab.title, tabId: tab.tabId, terminal: tab.handle, created, harnessStarted: running };
|
|
495
573
|
// Whether this run picked the session up where it was or started a new one.
|
|
496
574
|
// Only for a tab this run opened: a tab that was already there was left alone.
|
|
497
575
|
if (resumed !== undefined) made.resumed = resumed;
|
|
576
|
+
// The id the book held that had no conversation behind it, now in the history.
|
|
577
|
+
if (noConversation !== undefined) made.noConversation = noConversation;
|
|
498
578
|
// Orca's own words for what is on screen waiting to be answered, when it
|
|
499
579
|
// gave any: the caller acts on it, the kit only passes it on.
|
|
500
580
|
if (blockedReason !== undefined) made.blockedReason = blockedReason;
|
|
@@ -502,7 +582,7 @@ function entry(tab, { bot, name, created, running = false, blockedReason, prompt
|
|
|
502
582
|
// claims: the caller is told, because the kit will not pick one.
|
|
503
583
|
if (unclaimed !== undefined && unclaimed.length > 0) made.unclaimed = unclaimed;
|
|
504
584
|
// Only for a session this run started that had something to be told.
|
|
505
|
-
if (
|
|
585
|
+
if (promptReceived !== undefined) made.promptReceived = promptReceived;
|
|
506
586
|
// And the file it was told it out of, when it was too long for the line.
|
|
507
587
|
if (promptFile !== undefined) made.promptFile = promptFile;
|
|
508
588
|
return made;
|