@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/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 new Error(`Orca refused ${args.join(' ')}: ${answer.error?.message ?? 'no reason given'}`);
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 new Error(`Orca refused ${args.join(' ')}: ${answer.error?.message ?? 'no reason given'}`);
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, so the next check brings the one after it. */
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
- book.sessions[session] = withUnclaimed(rememberSession(was, id, said.source), unclaimed);
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
+ }
@@ -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
- const which = whichConversation(book, home, bot, session, was, harness);
266
- const resume = which.resume;
267
- const prompt = resume === undefined ? startPrompt(session, { home, workDir }) : undefined;
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
- const entry = { ...current.sessions[session.name], tab: made.tabId, launched };
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, so it is the harness that holds it until it is ready — through
338
- // its folder-trust question and its update offer. What is still worth saying
339
- // is whether the line took at all: a shell that swallowed it swallowed the
340
- // duty with it, and nobody has been told anything.
341
- const promptSent = prompt === undefined ? undefined : tui.running === true;
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
- promptSent,
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') return { resume: was.session };
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, promptSent, promptFile, resumed, unclaimed }) {
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 (promptSent !== undefined) made.promptSent = promptSent;
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;