@assuredloop/orca-bot-kit 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -263,12 +263,22 @@ name for a directory in `skills/` inside your bots folder, or anything with a
263
263
  `/` in it for a skill directory anywhere on disk. `obk up` links them before it
264
264
  opens a tab, and `bot create` gives a new bot what the lists already name.
265
265
 
266
+ A bot whose sessions are running gets a changed list without a restart.
267
+ When `skills build` links or takes away a skill, it tells each running
268
+ session of that bot. A Claude Code session gets `/reload-skills` typed into
269
+ its tab: Claude Code's own reload, which a busy session runs when its turn
270
+ ends. Codex has no reload command and takes the change at the start of its
271
+ next turn, so nothing is typed there. The kit prints the new skills'
272
+ `SKILL.md` paths, and says that a restart would make a skill appear if it is
273
+ still missing after that turn. A session the kit could not tell is named,
274
+ with the reason.
275
+
266
276
  What lands in the bot is a symlink, never a copy
267
277
  ([ADR 0014](docs/adr/0014-skills-are-linked-never-copied.md)) — into
268
278
  `.claude/skills` and `.agents/skills` in the bot home, which is where each
269
279
  harness reads a project's skills from. So a kit skill is read where npm
270
- installed it, editing a skill is what a running session reads without a
271
- restart, and updating the kit updates every bot at once.
280
+ installed it, a running session reads an edited skill the next time it loads
281
+ it, and updating the kit updates every bot at once.
272
282
 
273
283
  The kit takes away only what it can prove it put there. It writes down every
274
284
  link it makes, in `.obk-skills.yaml` in the bot folder, and when no list names
@@ -404,7 +414,10 @@ same refusals, and `obk up` leaves what is paused closed until `obk unpause`
404
414
  brings it back with its conversations. Retiring a session takes it off the bot
405
415
  and keeps its conversations in the book under `retired`. Retiring a bot closes
406
416
  its tabs, removes its Orca project and moves its folder to `retired/`; it will
407
- not touch a bot whose Orca project holds a tab your book does not name. Bot
417
+ not touch a bot whose Orca project holds a tab your book does not name. The
418
+ folder moves only once Orca no longer lists the project; if Orca still lists it,
419
+ or its list cannot be read, retire says so and leaves the bot where it is, and
420
+ retiring it again finishes the job. Bot
408
421
  Father itself, and its management session `daily`, are never paused or
409
422
  retired; its other sessions are like any bot's.
410
423
 
package/SETUP.md CHANGED
@@ -145,8 +145,9 @@ different answers.
145
145
 
146
146
  `obk init` tells you which, for every tab it opened:
147
147
 
148
- - *the harness was typed in and came up* — it is running. It may still be asking
149
- something of its own; that is the table below.
148
+ - *the harness was typed in and is running. The kit cannot see whether a screen
149
+ in it is waiting for an answer* — look at it: Orca does not flag every first-run
150
+ screen, so it may still be asking something of its own. That is the table below.
150
151
  - *the harness was typed in and came up, waiting on: …* — running and blocked on
151
152
  a question. The table below.
152
153
  - *the harness was typed in, and no session came up in the tab* — **the line did
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assuredloop/orca-bot-kit",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -273,17 +273,29 @@ again and check a session is running before you call the bot ready.
273
273
  ## When the bot is already running
274
274
 
275
275
  Changing a bot whose sessions are up is the ordinary case rather than the
276
- awkward one. Both harnesses read a skill through the link and notice a change
277
- to the skills directory while a session is running, so putting a skill on a
278
- bot, or taking one off, reaches a live session without restarting it.
279
-
280
- Noticing is not the same as confirmed. Check that the session can actually see
281
- the skill before telling anyone it is there, and check by asking that session
282
- rather than by looking again at the disk you just wrote: what you wrote is not
283
- evidence about what another process has loaded. Where it has not appeared, say
284
- so and say what would make it appear. A restart is the user's to ask for, and
285
- taking one because it is the quick way ends a conversation they were in the
286
- middle of.
276
+ awkward one. Putting a skill on a bot or taking one off reaches its running
277
+ sessions without a restart, and `obk skills build` says, session by session,
278
+ how:
279
+
280
+ - Claude Code: the kit types `/reload-skills` into the session's tab. That is
281
+ Claude Code's own reload. A busy session queues it and runs it as a command
282
+ when its turn ends, so it never reaches the model as text.
283
+ - Codex: nothing is typed, because Codex has no reload command. It takes the
284
+ change at the start of its next turn, not in the middle of the one it is on.
285
+
286
+ The kit reports a session it could not tell, and why: not up, or on a screen
287
+ waiting for an answer. A session that is not up reads its skills when it
288
+ starts.
289
+
290
+ Told is not the same as confirmed. Before telling anyone a skill is there,
291
+ check that the session can use it: ask it to load the skill. Do not look
292
+ again at the disk you just wrote, since that says nothing about what another
293
+ process has loaded. Do not ask a Codex session whether the skill is in its
294
+ list: it has answered "none" while its list held the skill. Where a skill has
295
+ not appeared after the next turn, say so. A restart would make it appear, and
296
+ meanwhile the session can read the skill's `SKILL.md` by the path the kit
297
+ printed. A restart is the user's to ask for. Taking one because it is quick
298
+ ends a conversation they were in the middle of.
287
299
 
288
300
  Instructions are not skills. A session is working from the rules it read when
289
301
  it started, so a change to a bot's `AGENTS.md` may not reach one that is
package/src/cli.js CHANGED
@@ -18,7 +18,7 @@ import { checkHealth, orcaSettingFindings } from './health.js';
18
18
  import { initBots } from './init.js';
19
19
  import { APPROVALS, HARNESSES, ownCli, shellWord, workDirOf } from './launch.js';
20
20
  import { checkMail, lookUp, sendMessage } from './message.js';
21
- import { orcaCli, orcaTrouble } from './orca.js';
21
+ import { orcaCli, orcaTrouble, RELOAD_LINE } from './orca.js';
22
22
  import { pauseSessions, unpauseSessions } from './pause.js';
23
23
  import { recordSession, SHELL_ENV, TAB_ENV } from './record.js';
24
24
  import { restartSessions } from './restart.js';
@@ -83,7 +83,8 @@ Usage:
83
83
  a bot: close its tabs, remove its Orca project and
84
84
  move its folder to retired/. It will not retire a
85
85
  bot whose Orca project holds a tab your book does
86
- not name.
86
+ not name, and moves the folder only once Orca no
87
+ longer lists the project.
87
88
  obk rules build --bots <path> [--bot <bot>]
88
89
  Build every bot's AGENTS.md from its charter and
89
90
  the rule units it carries, or just the one you
@@ -103,7 +104,10 @@ Usage:
103
104
  skills folder, or any path, and take away a link
104
105
  it made that no list names any more. What you put
105
106
  in a bot's skills directory yourself is left alone
106
- and shown as yours. It does not touch Orca.
107
+ and shown as yours. A bot whose links changed has
108
+ its running sessions told: Claude Code gets
109
+ /reload-skills typed in, and Codex takes the
110
+ change at its next turn.
107
111
  obk skills fetch --bots <path> [--source <name>]
108
112
  Clone the online sources skills.yaml lists, beside
109
113
  your bots folder and never inside it, each at the
@@ -425,19 +429,19 @@ const commands = {
425
429
  const seeded = initBots(bots, values.harness);
426
430
  // The typed path was for making the folder and naming it in a refusal; from
427
431
  // here on it is the fleet, and the fleet is its real path (#164).
428
- const { tabs, rules, skills, paused } = await bringUp(sameFleet(seeded.bots), { bot: BOT_FATHER });
432
+ const { tabs, rules, skills, paused, projects } = await bringUp(sameFleet(seeded.bots), { bot: BOT_FATHER });
429
433
  // Setup is the other place the PRD asks for Orca's own launch arguments to
430
434
  // be looked at (6.5), and the one where the user is still standing in front
431
435
  // of the fleet they are making. Only that one check: a folder init has just
432
436
  // made has nothing else to say about itself.
433
- const answer = { bots: seeded.bots, created: seeded.created, completed: seeded.completed, rules, skills, tabs, paused, found: orcaSettingFindings() };
437
+ const answer = { bots: seeded.bots, created: seeded.created, completed: seeded.completed, rules, skills, tabs, paused, projects, found: orcaSettingFindings() };
434
438
  return { answer, lines: tabLines(answer, `Bot Father is up in Orca. Your bots folder: ${seeded.bots}`) };
435
439
  },
436
440
 
437
441
  async up(bots, values) {
438
442
  refuseWhenOrcaIsDown();
439
- const { tabs, rules, skills, paused } = await bringUp(bots, { bot: values.bot, session: values.session });
440
- const answer = { bots, created: [], completed: [], rules, skills, tabs, paused };
443
+ const { tabs, rules, skills, paused, projects } = await bringUp(bots, { bot: values.bot, session: values.session });
444
+ const answer = { bots, created: [], completed: [], rules, skills, tabs, paused, projects };
441
445
  const up = [...new Set(tabs.map((tab) => tab.bot))];
442
446
  const summary = up.length === 0
443
447
  ? `Nothing was brought up in Orca. Your bots folder: ${bots}`
@@ -464,8 +468,8 @@ const commands = {
464
468
 
465
469
  async restart(bots, values) {
466
470
  refuseWhenOrcaIsDown();
467
- const { closed, tabs, rules, skills, paused } = await restartSessions(bots, { bot: values.bot, session: values.session });
468
- const answer = { bots, created: [], completed: [], rules, skills, tabs, paused, closed };
471
+ const { closed, tabs, rules, skills, paused, projects } = await restartSessions(bots, { bot: values.bot, session: values.session });
472
+ const answer = { bots, created: [], completed: [], rules, skills, tabs, paused, projects, closed };
469
473
  const what = values.session === undefined ? values.bot : `${values.bot} ${values.session}`;
470
474
  return {
471
475
  answer,
@@ -518,11 +522,25 @@ const commands = {
518
522
  }
519
523
 
520
524
  const retired = await retireBot(bots, { bot: values.bot });
525
+ if (retired.trouble !== undefined) {
526
+ return {
527
+ answer: { bots, ...retired },
528
+ lines: [
529
+ ...closedLines(retired.closed),
530
+ `${'trouble'.padEnd(9)} Orca project ${retired.project}`,
531
+ ` ${retired.trouble}`,
532
+ `Retire it again: ${shellWord(ownCli())} retire --bots ${shellWord(bots)} --bot ${retired.bot}`,
533
+ ],
534
+ code: 1,
535
+ };
536
+ }
521
537
  return {
522
538
  answer: { bots, ...retired },
523
539
  lines: [
524
540
  ...closedLines(retired.closed),
525
- ...(retired.project === undefined ? [] : [`removed Orca project ${retired.project}`]),
541
+ // After every removal, whatever the window was told: Orca's window can
542
+ // keep a removed project's row even after it re-reads (stablyai/orca#20102).
543
+ ...(retired.project === undefined ? [] : [`removed Orca project ${retired.project}`, RELOAD_LINE]),
526
544
  `retired ${retired.bot}: moved to ${path.relative(bots, retired.moved)}, with its book, charter and memory`,
527
545
  ],
528
546
  };
@@ -1084,9 +1102,24 @@ function skillsLines(skills) {
1084
1102
  ...(entry.trouble === undefined ? [] : [` ${'trouble'.padEnd(9)} ${entry.trouble}`]),
1085
1103
  ...entry.skills.map((skill) => ` ${(skill.managed ? 'linked' : 'yours').padEnd(9)} ${skill.name.padEnd(24)} ${skill.from ?? 'no list names it; the kit leaves it alone'}`),
1086
1104
  ...(entry.removed ?? []).map((name) => ` ${'removed'.padEnd(9)} ${name.padEnd(24)} no list names it now`),
1105
+ ...(entry.sessions ?? []).map((one) => toldLine(entry.bot, one)),
1087
1106
  ]);
1088
1107
  }
1089
1108
 
1109
+ /** What `skills build` did about one running session of a bot whose skills changed. */
1110
+ function toldLine(bot, { session, harness, state, read, blocked, trouble }) {
1111
+ const who = `${bot}/${session}`;
1112
+ const after = harness === 'claude' ? 'Once that is settled, type /reload-skills in its tab.' : 'Its next turn takes the change by itself.';
1113
+ const [word, says] = {
1114
+ reloaded: ['reloaded', '/reload-skills was typed into its tab; a busy session runs it when its turn ends'],
1115
+ 'next-turn': ['next turn', `Codex takes the change at the start of its next turn. If a skill is not there then, a restart makes it appear${read?.length > 0 ? `; meanwhile it can read ${read.join(', ')}` : ''}`],
1116
+ 'not-up': ['not told', 'not up; it reads its skills when it starts'],
1117
+ blocked: ['not told', `its tab is waiting for an answer (${blocked}), so nothing was typed. ${after}`],
1118
+ unknown: ['not told', `${trouble}. ${after}`],
1119
+ }[state];
1120
+ return ` ${word.padEnd(9)} ${who.padEnd(24)} ${says}`;
1121
+ }
1122
+
1090
1123
  /**
1091
1124
  * What a check found, two lines each: what kind of trouble it is and the one
1092
1125
  * thing to go and look at, then the sentence about it. The same shape wherever
@@ -1095,7 +1128,7 @@ function skillsLines(skills) {
1095
1128
  const foundLines = (found) =>
1096
1129
  found.flatMap((one) => [`${one.kind.padEnd(9)} ${one.where}`, ` ${one.says}`]);
1097
1130
 
1098
- function tabLines({ bots, created, completed, rules, skills, tabs, paused = [], found = [] }, summary) {
1131
+ function tabLines({ bots, created, completed, rules, skills, tabs, paused = [], projects = [], found = [] }, summary) {
1099
1132
  const lines = [
1100
1133
  ...created.map((entry) => `created ${entry}`),
1101
1134
  ...completed.map((entry) => `completed ${entry}`),
@@ -1115,7 +1148,10 @@ function tabLines({ bots, created, completed, rules, skills, tabs, paused = [],
1115
1148
 
1116
1149
  // Last before the summary, because what a check found is about the setup the
1117
1150
  // run has just left behind rather than about any one thing it did.
1118
- lines.push(...foundLines(found), summary);
1151
+ lines.push(...foundLines(found));
1152
+ // Hedged, because nothing here can see whether the window was told (#224).
1153
+ if (projects.length > 0) lines.push(RELOAD_LINE);
1154
+ lines.push(summary);
1119
1155
  return lines;
1120
1156
  }
1121
1157
 
@@ -1144,8 +1180,17 @@ function harnessLines(tab, bots) {
1144
1180
  ];
1145
1181
  }
1146
1182
 
1183
+ // Orca naming no reason does not mean nothing is on screen: on 1.4.209 it
1184
+ // names none for Codex's trust and hooks screens, nor for Claude Code's trust
1185
+ // list (tech notes, section 1, #288). So the kit says what it cannot see.
1147
1186
  const lines = tab.blockedReason === undefined
1148
- ? [...how, ' the harness was typed in and came up.']
1187
+ ? [
1188
+ ...how,
1189
+ ' the harness was typed in and is running. The kit cannot see whether a screen',
1190
+ ' in it is waiting for an answer.',
1191
+ ` Look at it: ${lookAt(tab.terminal)}`,
1192
+ ...ANSWER_IT,
1193
+ ]
1149
1194
  : [
1150
1195
  ...how,
1151
1196
  ` the harness was typed in and came up, waiting on: ${tab.blockedReason}`,
package/src/message.js CHANGED
@@ -21,7 +21,7 @@ import path from 'node:path';
21
21
  import { readBook } from './book.js';
22
22
  import { botDir, botNames, readBot } from './bot.js';
23
23
  import { harnessOf, ownCli, reachesMail, shellWord } from './launch.js';
24
- import { ackMailbox, harnessInTab, postMessage, readMailbox, tabs, typeIntoTab, useMailbox } from './orca.js';
24
+ import { ackMailbox, coordinatorOf, postMessage, readMailbox, tabs, tabToTypeInto, typeIntoTab, useMailbox } from './orca.js';
25
25
 
26
26
  /**
27
27
  * How much of a message travels as itself. Above this it is written to a file
@@ -173,7 +173,9 @@ export function sendMessage(bots, { to: target, from: sender, tab, subject, text
173
173
  /**
174
174
  * What is waiting for a session, and the reading of it.
175
175
  *
176
- * Orca fences a Run to one reader, so this binds before it reads. A read that
176
+ * Orca fences a Run to one reader, its coordinator. A session whose tab is live
177
+ * is bound to that tab and read as it; one whose tab is down is read as its
178
+ * Run's coordinator, and nothing is bound (issue #249). A read that
177
179
  * is not a peek acknowledges the batch it read: that is what makes the next
178
180
  * check bring the next one rather than the same again.
179
181
  */
@@ -195,9 +197,21 @@ export function checkMail(bots, { bot: botName, session: sessionName, tab, peek
195
197
  // Read as the session's own tab, wherever this check was typed. Binding the
196
198
  // tab that asked would hand it the session's mailbox, and Orca's notice for
197
199
  // every message after (issue #228). A session with no live tab is read as
198
- // this process's own terminal, as it always was.
199
- const handle = who.tab === undefined ? undefined : tabs(who.home).find((tab) => tab.tabId === who.tab)?.handle;
200
- useMailbox(who.mailbox, handle);
200
+ // whatever its mailbox is bound to, a closed tab included, and nothing is
201
+ // bound: the session's tab is bound again when it is back up (issue #249).
202
+ const live = who.tab === undefined ? undefined : tabs(who.home).find((tab) => tab.tabId === who.tab)?.handle;
203
+ if (live !== undefined) useMailbox(who.mailbox, live);
204
+ const handle = live ?? coordinatorOf(who.mailbox);
205
+ if (handle === undefined) {
206
+ return {
207
+ bots,
208
+ bot: who.bot,
209
+ session: who.session,
210
+ mailbox: who.mailbox,
211
+ messages: [],
212
+ trouble: `${who.bot}/${who.session} is not up, and its mailbox is bound to no tab, so there is nothing to read it as. Its mail waits until its tab is back: \`up\` brings it back, or \`unpause\` when it is paused.`,
213
+ };
214
+ }
201
215
  const found = readMailbox(who.mailbox, { peek, handle });
202
216
  const messages = (found.messages ?? []).map((message) => asMessage(bots, message));
203
217
  if (!peek && found.deliveryId !== undefined && messages.length > 0) ackMailbox(who.mailbox, found.deliveryId, handle);
@@ -321,31 +335,14 @@ function nudge(to, from, subject) {
321
335
  if (to.tab === undefined) return { nudged: false };
322
336
 
323
337
  try {
324
- const live = tabs(to.home).find((tab) => tab.tabId === to.tab);
325
- if (live === undefined) return { nudged: false };
326
-
327
- const seen = harnessInTab(live.handle, LOOK_MS);
328
- if (seen.blockedReason !== undefined) return { nudged: false, blocked: seen.blockedReason };
329
- // The shell in front is a tab with no harness, whatever a stale
330
- // `agentIdentity` says.
331
- if (seen.front === 'shell') return { nudged: false };
332
- // Not knowing is not a reason to type: a line that lands in a shell is run
333
- // there, with the sender's subject in it. The program in front has to be
334
- // the agent Orca names. With no name it may be a harness a few seconds
335
- // into its launch; under another name it may be a pager started after the
336
- // harness quit, under an identity Orca has not let go of (tech notes,
337
- // section 1). A harness run through a wrapper such as `node` lands here
338
- // too, until #261.
339
- if (seen.front === undefined || seen.agent === undefined || seen.command !== seen.agent) {
340
- const why = seen.front === undefined
341
- ? seen.unreadable
342
- : `${seen.command} holds its terminal, and Orca names ${seen.agent ?? 'no agent'} in it`;
343
- return { nudged: false, nudgeTrouble: `the kit could not tell whether a harness is running in it (${why}), so nothing was typed` };
344
- }
345
- // A busy harness is typed into: it takes the line as its next turn.
338
+ const found = tabToTypeInto(to.home, to.tab, LOOK_MS);
339
+ if (found.blocked !== undefined) return { nudged: false, blocked: found.blocked };
340
+ // A line that lands in a shell is run there, with the sender's subject in it.
341
+ if (found.unsure !== undefined) return { nudged: false, nudgeTrouble: found.unsure };
342
+ if (found.handle === undefined) return { nudged: false };
346
343
 
347
344
  typeIntoTab(
348
- live.handle,
345
+ found.handle,
349
346
  `Fleet mail from ${from.bot}/${from.session}: ${subject}. Read it with ${shellWord(ownCli())} message check --bots ${shellWord(to.bots)} --bot ${shellWord(to.bot)} --session ${shellWord(to.session)}`,
350
347
  );
351
348
  return { nudged: true };
@@ -0,0 +1,21 @@
1
+ // Run by Orca's own binary as plain Node, the way Orca's `bin/orca` runs its
2
+ // CLI: `ELECTRON_RUN_AS_NODE=1 <Orca> orca-window.cjs <client> <projectId> <wait>`.
3
+ //
4
+ // It asks Orca's runtime, through Orca's own client, for `project.update` with
5
+ // no changes on one project. That is the call that makes Orca's window read its
6
+ // projects again, and Orca's CLI does not offer it (#224, ADR 0021). It exits 0
7
+ // only when the runtime took the call; anything else is a non-zero exit, and
8
+ // the kit reads every non-zero exit the same way.
9
+
10
+ 'use strict';
11
+
12
+ const [client, projectId, wait] = process.argv.slice(2);
13
+
14
+ process.exitCode = 1;
15
+ const { RuntimeClient } = require(client);
16
+ new RuntimeClient(undefined, Number(wait))
17
+ .call('project.update', { projectId, updates: {} })
18
+ .then((answer) => {
19
+ if (answer?.ok === true) process.exitCode = 0;
20
+ }, () => {})
21
+ .finally(() => process.exit());
package/src/orca.js CHANGED
@@ -1,4 +1,4 @@
1
- // Every call the kit makes to Orca goes through here (ADR 0011). Orca's CLI
1
+ // Every call the kit makes to Orca goes through here (ADR 0021). Orca's CLI
2
2
  // changes often, so the kit reads `--json` and never the human text, and keeps
3
3
  // the parsing in one place.
4
4
  //
@@ -8,9 +8,10 @@
8
8
  // handed to Orca on the command line.
9
9
 
10
10
  import { spawnSync } from 'node:child_process';
11
- import { existsSync, readdirSync, readFileSync } from 'node:fs';
11
+ import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs';
12
12
  import { homedir } from 'node:os';
13
13
  import path from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
14
15
 
15
16
  // The Orca that works for a normal user: `/usr/local/bin/orca` is a root-only
16
17
  // symlink on this machine (tech notes, section 1). OBK_ORCA overrides it, for a
@@ -162,6 +163,49 @@ export function asFolderProject(setupId, title) {
162
163
  */
163
164
  export const deleteProject = (setupId) => orca(['project', 'setup-delete', '--setup', setupId]);
164
165
 
166
+ /** Said after a run that made, renamed or removed a project, whatever `tellWindow` answered. */
167
+ export const RELOAD_LINE = "If Orca's sidebar does not show it, reload the window with Cmd+Shift+R.";
168
+
169
+ /** How long Orca's client waits for its runtime, and how long the kit waits for the client. */
170
+ const CLIENT_WAIT_MS = 2000;
171
+ const CLIENT_KILL_MS = 3000;
172
+
173
+ /**
174
+ * Tell Orca's window that the project `projectId` changed, so it reads its
175
+ * projects again: true when Orca's runtime took the call, false otherwise.
176
+ *
177
+ * Orca's window re-reads only when its runtime says the projects changed, and
178
+ * `setup-update` and `setup-delete` do not say so. `project.update` with no
179
+ * changes does, and Orca's CLI does not offer it, so this goes through Orca's
180
+ * own runtime client out of the installed app, run by Orca's binary the way
181
+ * its `bin/orca` runs its CLI (ADR 0021). None of that is Orca's published
182
+ * interface, so anything that goes wrong is a quiet false: it never throws and
183
+ * is never tried twice, and the caller prints `RELOAD_LINE` either way.
184
+ */
185
+ export function tellWindow(projectId) {
186
+ try {
187
+ // The CLI sits at <Orca.app>/Contents/Resources/bin/orca, often reached
188
+ // through a link.
189
+ const contents = path.resolve(realpathSync(orcaCli()), '..', '..', '..');
190
+ const client = path.join(contents, 'Resources', 'app.asar.unpacked', 'out', 'cli', 'runtime-client.js');
191
+ const env = { ...process.env, ELECTRON_RUN_AS_NODE: '1' };
192
+ // As Orca's `bin/orca` does: the kit's own Node options are not Orca's.
193
+ delete env.NODE_OPTIONS;
194
+ delete env.NODE_REPL_EXTERNAL_MODULE;
195
+
196
+ const asked = spawnSync(
197
+ path.join(contents, 'MacOS', 'Orca'),
198
+ [WINDOW_SCRIPT, client, projectId, String(CLIENT_WAIT_MS)],
199
+ { env, stdio: 'ignore', timeout: CLIENT_KILL_MS },
200
+ );
201
+ return asked.error === undefined && asked.status === 0;
202
+ } catch {
203
+ return false;
204
+ }
205
+ }
206
+
207
+ const WINDOW_SCRIPT = fileURLToPath(new URL('./orca-window.cjs', import.meta.url));
208
+
165
209
  /**
166
210
  * The live tabs of the Orca project at `home`, each under its own tab id.
167
211
  *
@@ -226,6 +270,48 @@ export function harnessInTab(handle, timeoutMs) {
226
270
  };
227
271
  }
228
272
 
273
+ /**
274
+ * Whether the kit may type a line into `tabId`, the tab the book holds for a
275
+ * session in the Orca project at `home`, and the handle to type into when it
276
+ * may: `{ handle, agent }`, with the agent Orca names there. Otherwise nothing
277
+ * is typed, and the answer says why:
278
+ * `{}` for a tab with no harness in it (none in the book, none Orca lists, or
279
+ * the shell in front), `{ blocked }` for one with something on screen waiting
280
+ * to be answered, and `{ unsure }`, a sentence, for one the kit cannot tell
281
+ * about. Orca refusing throws, for the caller to report.
282
+ *
283
+ * The one gate for every line the kit types into a running session: the mail
284
+ * nudge and the skills reload. A busy harness passes it, since both harnesses
285
+ * take a typed line as their next turn.
286
+ */
287
+ export function tabToTypeInto(home, tabId, timeoutMs) {
288
+ if (tabId === undefined) return {};
289
+ const live = tabs(home).find((tab) => tab.tabId === tabId);
290
+ if (live === undefined) return {};
291
+
292
+ const seen = harnessInTab(live.handle, timeoutMs);
293
+ // A line typed into a screen waiting for an answer is that answer: once, it
294
+ // confirmed Claude Code's folder-trust default, `No, exit` (tech notes,
295
+ // section 1).
296
+ if (seen.blockedReason !== undefined) return { blocked: seen.blockedReason };
297
+ // The shell in front is a tab with no harness, whatever a stale
298
+ // `agentIdentity` says.
299
+ if (seen.front === 'shell') return {};
300
+ // Not knowing is not a reason to type: a line that lands in a shell is run
301
+ // there. The program in front has to be the agent Orca names. With no name
302
+ // it may be a harness a few seconds into its launch; under another name it
303
+ // may be a pager started after the harness quit, under an identity Orca has
304
+ // not let go of (tech notes, section 1). A harness run through a wrapper such
305
+ // as `node` lands here too, until #261.
306
+ if (seen.front === undefined || seen.agent === undefined || seen.command !== seen.agent) {
307
+ const why = seen.front === undefined
308
+ ? seen.unreadable
309
+ : `${seen.command} holds its terminal, and Orca names ${seen.agent ?? 'no agent'} in it`;
310
+ return { unsure: `the kit could not tell whether a harness is running in it (${why}), so nothing was typed` };
311
+ }
312
+ return { handle: live.handle, agent: seen.agent };
313
+ }
314
+
229
315
  /** The `ps` this run reads. OBK_PS overrides it, as OBK_ORCA does Orca. */
230
316
  const psCli = () => process.env.OBK_PS || '/bin/ps';
231
317
 
@@ -235,7 +321,7 @@ const psCli = () => process.env.OBK_PS || '/bin/ps';
235
321
  * group in front, or `{ unreadable: <why> }`.
236
322
  *
237
323
  * Orca gives the pane's pid in `diagnostics memory` and nowhere else, and `ps`
238
- * gives that pid's terminal's foreground process group (ADR 0011).
324
+ * gives that pid's terminal's foreground process group (ADR 0021).
239
325
  * On macOS the pane is `login` with the shell as its child, so the shell is in
240
326
  * front when the group is the pane's own or that of a child of a `login` pane.
241
327
  * `diagnostics memory` is a diagnostics command and may change, so everything
@@ -326,17 +412,23 @@ export const makeMailbox = (objective, handle) =>
326
412
  orca(['orchestration', 'run-create', '--objective', `obk ${objective}`, '--from', handle]).run.id;
327
413
 
328
414
  /**
329
- * Take a turn at reading a mailbox, as the terminal `handle`.
330
- *
331
- * Orca fences a Run to one reader: a `check` from a terminal bound elsewhere is
332
- * refused with `consumer_fenced`, whatever the Run says. So a read binds first,
333
- * every time — the kit's runs are short and the binding is the last one to have
334
- * asked, not a lease anybody has to give back. Without a handle it binds this
335
- * process's own terminal.
415
+ * Bind a mailbox to the terminal `handle`, which makes it the Run's coordinator
416
+ * and its one reader: Orca refuses a `check` from any other terminal with
417
+ * `consumer_fenced`. A read itself binds nothing, so this is for a session's own
418
+ * live tab, never for whoever is reading (tech notes, section 1). Without a
419
+ * handle it binds this process's own terminal.
336
420
  */
337
421
  export const useMailbox = (id, handle) =>
338
422
  orca(['orchestration', 'run-use', '--id', id, ...(handle === undefined ? [] : ['--from', handle])]).run;
339
423
 
424
+ /**
425
+ * The terminal a mailbox is bound to, or undefined when it has none. A closed
426
+ * tab keeps its binding, and a read as its handle still works: a read binds
427
+ * nothing, only `run-use` does (tech notes, section 1).
428
+ */
429
+ export const coordinatorOf = (id) =>
430
+ orca(['orchestration', 'run-show', '--id', id]).run.coordinator_handle ?? undefined;
431
+
340
432
  /** Queue one message. `to` and `from` are mailboxes, written `run:<id>`. */
341
433
  export function postMessage({ to, from, subject, body, type = 'status', thread }) {
342
434
  const args = ['orchestration', 'send', '--to', to, '--subject', subject, '--body', body, '--type', type];
package/src/retire.js CHANGED
@@ -16,16 +16,16 @@
16
16
  // restart or a pause it does not wait for the book to know which one it was.
17
17
  // The mailbox Runs stay: Orca has no way to remove one (ADR 0018).
18
18
 
19
- import { existsSync, mkdirSync, renameSync, rmSync } from 'node:fs';
19
+ import { existsSync, mkdirSync, realpathSync, renameSync, rmSync } from 'node:fs';
20
20
  import path from 'node:path';
21
21
 
22
22
  import { readBook, tabIdsIn, updateBook } from './book.js';
23
23
  import { botDir, dropSession, readBot } from './bot.js';
24
- import { deleteProject, findProject, tabs } from './orca.js';
24
+ import { deleteProject, findProject, projects, tabs, tellWindow } from './orca.js';
25
25
  import { fleetMember } from './pause.js';
26
26
  import { closeTabs, tabsToClose } from './restart.js';
27
27
  import { unlinkSkills } from './skills.js';
28
- import { promptPath, sessionsOf } from './up.js';
28
+ import { BOT_FATHER, promptPath, sessionsOf } from './up.js';
29
29
 
30
30
  /** Where retired bots go: beside `bots/`, where nothing the kit runs looks. */
31
31
  export const retiredDir = (bots) => path.join(bots, 'retired');
@@ -55,6 +55,8 @@ export async function retireSession(bots, { bot, session }) {
55
55
  /**
56
56
  * Retire the bot `bot`. Returns `{ bot, closed, project, moved }`: the tabs it
57
57
  * closed, the Orca project it took away (if it had one) and where the bot is now.
58
+ * When Orca does not confirm the project gone, it returns `{ bot, closed,
59
+ * project, trouble }` instead, and the bot is left where it was.
58
60
  */
59
61
  export async function retireBot(bots, { bot }) {
60
62
  const home = fleetMember(bots, bot, 'retire');
@@ -79,7 +81,24 @@ export async function retireBot(bots, { bot }) {
79
81
  // they are gone.
80
82
  const booked = Object.keys(readBook(home).sessions).map((name) => ({ name }));
81
83
  const closed = await closeTabs(home, tabsToClose(bots, bot, home, booked, { keepless: true }), bots, bot);
82
- if (project !== undefined) deleteProject(project.id);
84
+ if (project !== undefined) {
85
+ deleteProject(project.id);
86
+ // Orca's answer to the delete is not the same as the project being gone
87
+ // (#282): its list afterwards is. Until that list is read and no longer
88
+ // has the project, the bot stays where it is, so that retiring it again
89
+ // takes the project away and then finishes. The list is read once, for
90
+ // this and for the window both.
91
+ let setups;
92
+ try {
93
+ setups = projects();
94
+ } catch (error) {
95
+ return { bot, closed, project: project.id, trouble: `Orca answered the delete of Orca project ${project.id}, and its project list could not be read afterwards, so the removal is not confirmed: ${error.message}. ${bot} was not moved. Once Orca is answering, retire ${bot} again with obk retire: it removes the project if it is still there, then finishes.` };
96
+ }
97
+ if (setups.some((setup) => setup.id === project.id || setup.path === home)) {
98
+ return { bot, closed, project: project.id, trouble: `Orca answered the delete of Orca project ${project.id}, and still lists it. ${bot} was not moved. Retire ${bot} again with obk retire; if Orca still lists the project after that, remove it in Orca yourself, then retire ${bot} again.` };
99
+ }
100
+ tellWindowOfRemoval(bots, setups);
101
+ }
83
102
 
84
103
  for (const name of new Set([...known.sessions, ...booked].map((session) => session.name))) {
85
104
  rmSync(promptPath(bots, bot, name), { force: true });
@@ -90,3 +109,19 @@ export async function retireBot(bots, { bot }) {
90
109
 
91
110
  return { bot, closed, ...(project === undefined ? {} : { project: project.id }), moved };
92
111
  }
112
+
113
+ /**
114
+ * Tell Orca's window a project went (#224). The call names a project that is
115
+ * still there, and Bot Father's is the one that always is; when Orca has none
116
+ * for it in `setups`, Orca's list after the delete, there is nothing to call
117
+ * on. Finding it is part of the workaround, so it fails as quietly as the call.
118
+ */
119
+ function tellWindowOfRemoval(bots, setups) {
120
+ try {
121
+ const home = botDir(bots, BOT_FATHER);
122
+ const father = existsSync(home) ? setups.find((setup) => setup.path === realpathSync(home)) : undefined;
123
+ if (father !== undefined) tellWindow(father.projectId);
124
+ } catch {
125
+ // Nothing: see above.
126
+ }
127
+ }
package/src/skills.js CHANGED
@@ -3,25 +3,30 @@
3
3
  //
4
4
  // Linked, never copied, in both directions. Inwards: what lands in the bot home
5
5
  // is a symlink to the skill's own directory, so a kit skill is read where the
6
- // package installed it and an edit to a skill is what a running session reads —
7
- // both harnesses follow a symlinked skill folder and pick a change up without a
8
- // restart (tech notes, sections 2 and 3). Outwards: the kit takes away only the
9
- // links it put there, and never the skill one was pointing at.
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
- const done = link(path.join(home, SKILL_DIRS[harness]), wanted, record[harness]);
222
- removed.push(...done.removed);
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 asks Orca nothing.
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
- return linkSkills(bots, home, readBot(home, name));
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 took away, and
582
+ * kit is allowed to. Returns `{ linked, removed, held }` — the names it linked
583
+ * in or repointed, the names it took away (added to the lists given, as it
584
+ * goes, so a caller whose run throws still has them), and
493
585
  * what is in the directory afterwards.
494
586
  *
495
587
  * The kit acts only where the entry on disk is still the link it wrote, which
@@ -497,12 +589,10 @@ function follow(bots, ref, sources) {
497
589
  * link the very same skill by hand — so the kit remembers what it made rather
498
590
  * than guessing from where a link points (PRD 6.7).
499
591
  */
500
- function link(dir, wanted, record) {
592
+ function link(dir, wanted, record, linked = [], removed = []) {
501
593
  // The directory is there: `linkSkills` makes both before it calls this.
502
594
 
503
595
  const keep = new Map(wanted.map((skill) => [skill.name, skill]));
504
- const removed = [];
505
-
506
596
  // What the lists no longer name. The kit takes back only its own, and only
507
597
  // while it is untouched; anything else it forgets about and leaves.
508
598
  for (const name of Object.keys(record)) {
@@ -541,9 +631,10 @@ function link(dir, wanted, record) {
541
631
  // repo, and the link has to reach out of the repo to find it (ADR 0014).
542
632
  symlinkSync(skill.dir, at);
543
633
  record[skill.name] = skill.dir;
634
+ linked.push(skill.name);
544
635
  }
545
636
 
546
- return { removed, held: heldIn(dir, record) };
637
+ return { linked, removed, held: heldIn(dir, record) };
547
638
  }
548
639
 
549
640
  /**
package/src/up.js CHANGED
@@ -12,7 +12,7 @@ import { botDir, botNames, displayName, readBot } from './bot.js';
12
12
  import { conversationsIn } from './conversations.js';
13
13
  import { installHook } from './hooks.js';
14
14
  import { addressOf, harnessOf, isShortPrompt, launchCommand, reachesMail, sessionTrouble, startPrompt, workDirOf } from './launch.js';
15
- import { asFolderProject, findProject, harnessInTab, makeMailbox, makeProject, openTab, retitleTab, tabs, typeIntoTab, useMailbox } from './orca.js';
15
+ import { asFolderProject, findProject, harnessInTab, makeMailbox, makeProject, openTab, retitleTab, tabs, tellWindow, typeIntoTab, useMailbox } from './orca.js';
16
16
  import { buildAgents } from './rules.js';
17
17
  import { linkSkills } from './skills.js';
18
18
 
@@ -35,6 +35,7 @@ const SECOND_LOOK_MS = 2000;
35
35
  * Bring bots up in Orca. Returns `{ tabs, rules }`: one tab entry per tab it
36
36
  * looked at — the sessions the book knows, and, for Bot Father, whatever else
37
37
  * is open in its project — and one rules entry and one skills entry per bot.
38
+ * `projects` has one entry per Orca project this run made or renamed.
38
39
  *
39
40
  * With no name it is every bot in the folder, in name order. `bot` brings up
40
41
  * one bot and `session` one of its sessions, for a caller that wants one thing
@@ -58,11 +59,14 @@ export async function bringUp(bots, { bot: onlyBot, session: onlySession } = {})
58
59
  const { running, rules, skills } = prepareBots(bots, names, onlySession);
59
60
 
60
61
  const report = [];
62
+ const projects = [];
61
63
  for (const { bot, home } of running) {
62
64
  for (const session of sessionsOf(bot, onlySession)) {
63
65
  if (session.paused === true) paused.push({ bot: bot.name, session: session.name });
64
66
  }
65
- report.push(...await bringUpBot(bots, home, bot, onlySession));
67
+ const up = await bringUpBot(bots, home, bot, onlySession);
68
+ report.push(...up.tabs);
69
+ if (up.project !== undefined) projects.push(up.project);
66
70
  }
67
71
 
68
72
  // A bot whose rules would not build is reported here rather than in the
@@ -78,6 +82,7 @@ export async function bringUp(bots, { bot: onlyBot, session: onlySession } = {})
78
82
  })),
79
83
  skills,
80
84
  paused,
85
+ projects,
81
86
  };
82
87
  }
83
88
 
@@ -197,8 +202,11 @@ async function bringUpBot(bots, home, bot, onlySession) {
197
202
  // Orca is asked first and the book is written after: nothing that takes time
198
203
  // happens while the book is held, because a session's own hook may be writing
199
204
  // its id into that same file at any moment.
200
- const orca = orcaProject(home, title);
205
+ const { change, ...orca } = orcaProject(home, title);
201
206
  await updateBook(home, (book) => { book.orca = orca; });
207
+ // Orca's window does not see a project made or renamed on the command line
208
+ // until it is told (#224).
209
+ if (change !== undefined) tellWindow(orca.project);
202
210
 
203
211
  const live = new Map(tabs(home).map((tab) => [tab.tabId, tab]));
204
212
  const report = [];
@@ -217,7 +225,7 @@ async function bringUpBot(bots, home, bot, onlySession) {
217
225
  : spare.map((tab) => entry(tab, { bot: name, name: null, created: false }))));
218
226
  }
219
227
 
220
- return report;
228
+ return { tabs: report, project: change === undefined ? undefined : { bot: name, project: orca.project, change } };
221
229
  }
222
230
 
223
231
  async function bringUpSession(bots, home, live, session, bot, title) {
@@ -455,7 +463,8 @@ function whichConversation(book, home, bot, session, was, harness) {
455
463
  }
456
464
 
457
465
  /**
458
- * A bot's Orca project, made if it is not there yet.
466
+ * A bot's Orca project, made if it is not there yet, with `change` saying
467
+ * whether this run `made` it, `renamed` it or left it as it was (undefined).
459
468
  *
460
469
  * It has to be a folder workspace. Registering a folder that sits inside the
461
470
  * bots repo gets it recorded as a git one, which Orca gives no workspace at
@@ -463,12 +472,12 @@ function whichConversation(book, home, bot, session, was, harness) {
463
472
  */
464
473
  function orcaProject(home, title) {
465
474
  const found = findProject(home);
466
- const setup = found === undefined
467
- ? makeProject(home, title)
468
- : (found.kind === 'folder' ? found : asFolderProject(found.id, title));
469
- return { project: setup.projectId, setup: setup.id };
475
+ if (found === undefined) return ids(makeProject(home, title), 'made');
476
+ return found.kind === 'folder' ? ids(found) : ids(asFolderProject(found.id, title), 'renamed');
470
477
  }
471
478
 
479
+ const ids = (setup, change) => ({ project: setup.projectId, setup: setup.id, change });
480
+
472
481
  /**
473
482
  * Where the kit leaves a prompt for the launch line to pick up: a folder of its
474
483
  * own beside the bots repo, the way skill-source clones sit beside it (PRD 6.3)