@assuredloop/orca-bot-kit 0.36.1 → 0.37.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
@@ -474,6 +474,18 @@ It closes the session's tab — one tab, by its own handle — and opens a new o
474
474
  with the conversation the book holds, so the session comes back as itself. With
475
475
  no `--session` it does that for every session of the bot.
476
476
 
477
+ Closing the old tab ends the harness there, and the background shells it ran
478
+ end with it: a timer, a watch, a long build. So once a session whose tab the
479
+ restart closed is back with its conversation, the kit types one line into its
480
+ new tab. A session whose tab was already closed, or that starts fresh, gets no
481
+ line. The line says that its background shells
482
+ ended, that the kit cannot tell which ones ran or whether a scheduled job still
483
+ runs, and that it should look at its scheduled jobs before it makes one again.
484
+ It also asks the session to read its mail and do what it asks. The line goes in
485
+ only when the tab shows no question, form or menu, within 20 seconds. The
486
+ report says under each tab whether the line went in. When it did not, it says
487
+ why, and you tell the session yourself.
488
+
477
489
  What it will not do, in order of how much it would cost you. It closes only a
478
490
  tab your book names, so Bot Father's ops tab and anything you opened yourself
479
491
  are left alone. It will not close a tab whose conversation the book cannot name:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assuredloop/orca-bot-kit",
3
- "version": "0.36.1",
3
+ "version": "0.37.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
package/src/cli.js CHANGED
@@ -1988,6 +1988,7 @@ function harnessLines(tab, bots) {
1988
1988
  ` Look at it: ${lookAt(tab.terminal)}`,
1989
1989
  ...ANSWER_IT,
1990
1990
  ...promptLines(tab),
1991
+ ...restartLines(tab),
1991
1992
  ];
1992
1993
  }
1993
1994
 
@@ -2009,7 +2010,7 @@ function harnessLines(tab, bots) {
2009
2010
  ...ANSWER_IT,
2010
2011
  ];
2011
2012
 
2012
- lines.push(...promptLines(tab), ...listLines(tab));
2013
+ lines.push(...promptLines(tab), ...listLines(tab), ...restartLines(tab));
2013
2014
  if (tab.unclaimed !== undefined) lines.push(...unclaimedLines(tab, bots));
2014
2015
  return lines;
2015
2016
  }
@@ -2024,6 +2025,16 @@ function listLines(tab) {
2024
2025
  return [];
2025
2026
  }
2026
2027
 
2028
+ /**
2029
+ * Whether a session `obk restart` resumed was typed the line that tells it what
2030
+ * the restart ended (#726). When it was not, the caller is the one left to tell it.
2031
+ */
2032
+ function restartLines(tab) {
2033
+ if (tab.restartLine === true) return [' it was typed one line that tells it what the restart ended: its background shells, and to look at its scheduled jobs and its mail.'];
2034
+ if (tab.restartLine === false) return [` it was not told what the restart ended (${tab.restartLineTrouble}): tell it yourself that its background shells ended, and to look at its scheduled jobs and its mail.`];
2035
+ return [];
2036
+ }
2037
+
2027
2038
  /**
2028
2039
  * Whether the session got its start prompt, as its own record has it (#274):
2029
2040
  * received, or not confirmed yet. Nothing for a session that was told nothing.
package/src/message.js CHANGED
@@ -16,6 +16,7 @@
16
16
  // Escape a sender asks for with --interrupt. Orca gives no notice to a tab its
17
17
  // window has not loaded, so the send says when the receiver's is one (#579).
18
18
 
19
+ import { randomUUID } from 'node:crypto';
19
20
  import { mkdirSync, readdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
20
21
  import { homedir } from 'node:os';
21
22
  import path from 'node:path';
@@ -24,7 +25,7 @@ import { setTimeout as pause } from 'node:timers/promises';
24
25
  import { MAILBOX_WAIT_MS, readBook, takeLineTurn, takeMailboxTurn, TYPING_HELD, TYPING_WAIT_MS } from './book.js';
25
26
  import { botDir, botNames, readBot } from './bot.js';
26
27
  import { harnessOf, isAddressOf, kitFolders, ownCli, reachesMail, SHELL_ENV, shellWord } from './launch.js';
27
- import { ackMailbox, coordinatorOf, isRunning, listedOrphaned, loadInWindow, postMessage, pressEscape, readMailbox, tabs, tabToTypeInto, TERMINAL_ENV, TIMED_OUT, useMailbox } from './orca.js';
28
+ import { ackMailbox, coordinatorOf, isRunning, listedOrphaned, loadInWindow, postMessage, pressEscape, readMailbox, readsCarryRequest, tabs, tabToTypeInto, TERMINAL_ENV, TIMED_OUT, useMailbox } from './orca.js';
28
29
 
29
30
  /**
30
31
  * How much of a message travels as itself. Above this it is written to a file
@@ -365,10 +366,18 @@ function mailOf(bots, who, peek, until) {
365
366
  // acknowledging is handed over again to the next. From 1.4.246 a read
366
367
  // acknowledges the batch it hands over and says so (`deliveryAcknowledged`),
367
368
  // and an acknowledgement answers with no batch, so the next is read again
368
- // (#729; read in the 1.4.246 and 1.4.255 source).
369
+ // (#729; read in the 1.4.246 and 1.4.255 source). So a read that is not a
370
+ // peek is asked again, under the same request id, when Orca does not answer
371
+ // it in time (#735).
369
372
  const messages = [];
370
373
  const read = { bots, bot: who.bot, session: who.session, mailbox: who.mailbox, read: !peek, messages };
371
- let found = readMailbox(who.mailbox, { peek, handle }, timeLeft(until));
374
+ let found;
375
+ try {
376
+ found = peek ? readMailbox(who.mailbox, { peek, handle }, timeLeft(until)) : take(who.mailbox, handle, until, timeLeft(until));
377
+ } catch (error) {
378
+ if (peek || error.code !== TIMED_OUT) throw error;
379
+ return { ...read, trouble: `${error.message} Orca may have taken letters as read without the kit seeing them. Other mail waits for the next check.` };
380
+ }
372
381
  for (;;) {
373
382
  const batch = (found.messages ?? []).map((message) => asMessage(bots, message));
374
383
  if (peek || !found.deliveryId || batch.length === 0) {
@@ -383,7 +392,7 @@ function mailOf(bots, who, peek, until) {
383
392
  return { ...read, trouble: `${who.bot}/${who.session} has more mail than one check reads in ${HOLD_MS / 1000} seconds, so it stopped there. The rest is still waiting: check again.` };
384
393
  }
385
394
  try {
386
- found = readMailbox(who.mailbox, { handle }, wait);
395
+ found = take(who.mailbox, handle, until, wait);
387
396
  } catch (error) {
388
397
  // A read Orca did not answer about may have taken the next batch all
389
398
  // the same, and taken is read: that batch is not handed over again.
@@ -417,6 +426,31 @@ function mailOf(bots, who, peek, until) {
417
426
  }
418
427
  }
419
428
 
429
+ /**
430
+ * Take the next batch of `mailbox`, read as `handle`, under a request id of
431
+ * its own, the first call given `options` (#735). Orca keeps the answer of a
432
+ * read under its id: the same read asked again with that id answers the same
433
+ * batch and takes nothing new, waits for the first if it is still running,
434
+ * and runs as new if the first never reached Orca (read in the 1.4.246 and
435
+ * 1.4.255 source). So a read Orca does not answer in time is asked again with
436
+ * the same id while the check's hold lasts, and a batch Orca took as read is
437
+ * shown once. No other call is asked again: a send, a bind or an ack Orca did
438
+ * not answer about is not sent twice (#734).
439
+ */
440
+ function take(mailbox, handle, until, options) {
441
+ const request = randomUUID();
442
+ for (;;) {
443
+ try {
444
+ return readMailbox(mailbox, { handle, request }, options);
445
+ } catch (error) {
446
+ // A read that went without the id is not asked again (#734).
447
+ if (error.code !== TIMED_OUT || !readsCarryRequest()) throw error;
448
+ options = timeLeft(until, false);
449
+ if (options === undefined) throw error;
450
+ }
451
+ }
452
+ }
453
+
420
454
  /**
421
455
  * How long a check holds the session's turn, all its Orca calls together. Well
422
456
  * inside the minute `up` waits for the turn before it writes a new tab, so a
package/src/orca.js CHANGED
@@ -1021,6 +1021,36 @@ function panePid(ptyId, readMs) {
1021
1021
  return { pane };
1022
1022
  }
1023
1023
 
1024
+ /**
1025
+ * The ids of the ptys Orca holds a process for, from `diagnostics memory`, as
1026
+ * a set, or undefined when the kit cannot tell (#733). Each session there is a
1027
+ * pty of Orca's own registry, which drops a pty once its process has ended
1028
+ * (read in the 1.4.255 bundle; seen live, the pty went from the answer as its
1029
+ * pane process and shell ended). An Orca that refuses the command, or an
1030
+ * answer in another shape, cannot tell. Nor can an answer with no worktree at
1031
+ * all: Orca answers a snapshot that failed with an empty list (read in the
1032
+ * 1.4.255 bundle), and the kit cannot tell that from a machine with no
1033
+ * terminal.
1034
+ */
1035
+ export function livePtys() {
1036
+ let worktrees;
1037
+ try {
1038
+ worktrees = orca(['diagnostics', 'memory']).worktrees;
1039
+ } catch {
1040
+ return undefined;
1041
+ }
1042
+ if (!Array.isArray(worktrees) || worktrees.length === 0) return undefined;
1043
+ const ids = new Set();
1044
+ for (const worktree of worktrees) {
1045
+ if (!Array.isArray(worktree?.sessions)) return undefined;
1046
+ for (const session of worktree.sessions) {
1047
+ if (typeof session?.sessionId !== 'string') return undefined;
1048
+ ids.add(session.sessionId);
1049
+ }
1050
+ }
1051
+ return ids;
1052
+ }
1053
+
1024
1054
  /** The `lsof` and `stty` the kit asks, by their fixed paths, as `ps`. */
1025
1055
  export const LSOF = '/usr/sbin/lsof';
1026
1056
  const STTY = '/bin/stty';
@@ -1299,9 +1329,37 @@ export function postMessage({ to, from, subject, body, type = 'status', thread }
1299
1329
  * and says so with `deliveryAcknowledged: true` (#729). Read as
1300
1330
  * the terminal `handle` when one is given, which is how a read works from a tab
1301
1331
  * bound to another Run; otherwise as this process's own terminal.
1332
+ *
1333
+ * A read given a `request` id carries it as `--retry-request`: Orca keeps its
1334
+ * answer under that id, and the same read asked again with it answers the
1335
+ * same batch and takes nothing new (#735). An Orca that refuses the flag as
1336
+ * unknown did nothing, and is asked again without it, as for `--full`.
1337
+ */
1338
+ export function readMailbox(id, { peek = false, handle, request } = {}, options) {
1339
+ const args = ['orchestration', 'check', '--run', id, ...as(handle), ...(peek ? ['--peek'] : [])];
1340
+ if (request !== undefined && retryKnown) {
1341
+ try {
1342
+ return orchestration([...args, '--retry-request', request], options);
1343
+ } catch (error) {
1344
+ if (error.code !== 'invalid_argument' || error.reason !== RETRY_UNKNOWN) throw error;
1345
+ retryKnown = false;
1346
+ }
1347
+ }
1348
+ return orchestration(args, options);
1349
+ }
1350
+
1351
+ /** Orca's own words when its CLI does not know `--retry-request` on `check`: nothing was done. */
1352
+ const RETRY_UNKNOWN = 'Unknown flag --retry-request for command: orchestration check';
1353
+
1354
+ /** Whether this Orca may know `--retry-request` on `check`: false once it has refused it. */
1355
+ let retryKnown = true;
1356
+
1357
+ /**
1358
+ * Whether a read given a request id still carries it. Once Orca has refused
1359
+ * the flag, a read goes without it, and a read with no id is never asked again
1360
+ * after a time-out: it would take another batch, not answer the first (#735).
1302
1361
  */
1303
- export const readMailbox = (id, { peek = false, handle } = {}, options) =>
1304
- orchestration(['orchestration', 'check', '--run', id, ...as(handle), ...(peek ? ['--peek'] : [])], options);
1362
+ export const readsCarryRequest = () => retryKnown;
1305
1363
 
1306
1364
  /**
1307
1365
  * The newest `limit` messages in a mailbox, newest first, with every column
package/src/restart.js CHANGED
@@ -34,7 +34,7 @@ import { setTimeout as pause } from 'node:timers/promises';
34
34
  import { bookFile, readBook } from './book.js';
35
35
  import { botDir, readBot } from './bot.js';
36
36
  import { ownCli, shellWord } from './launch.js';
37
- import { closeTab, closeTerminal, findProject, frontOfTab, orcaCli, tabs } from './orca.js';
37
+ import { closeTab, closeTerminal, findProject, frontOfTab, livePtys, orcaCli, tabs } from './orca.js';
38
38
  import { botsNamed, bringUp, prepareBots, sessionsOf } from './up.js';
39
39
 
40
40
  /**
@@ -69,7 +69,9 @@ export async function restartSessions(bots, { bot: name, session: onlySession }
69
69
 
70
70
  const going = tabsToClose(bots, name, home, sessions.filter((session) => session.paused !== true), { quit: true });
71
71
  const closed = await closeTabs(home, going, bots, name, commandLine('restart', bots, name, onlySession));
72
- return { closed, ...(await bringUp(bots, { bot: name, session: onlySession })) };
72
+ // The sessions whose tabs this closed are the ones the restart ended
73
+ // something of, and the ones told so once they are back (#726).
74
+ return { closed, ...(await bringUp(bots, { bot: name, session: onlySession, restarted: new Set(closed.map((tab) => tab.name)) })) };
73
75
  }
74
76
 
75
77
  /**
@@ -114,9 +116,11 @@ export function tabsToClose(bots, name, home, sessions, { keepless = false, quit
114
116
  * Close the tabs `tabsToClose` gave, one at a time by their own handles, and
115
117
  * wait until Orca's listing agrees they are gone. Returns one entry per tab.
116
118
  * `again` is the command that asked, as the user would run it again: what to do
117
- * once a tab Orca would not close has been closed by hand.
119
+ * once a tab Orca would not close has been closed by hand. With `retiring`, the
120
+ * caller starts no harness in the tabs' place, so a tab Orca still lists after
121
+ * the wait may count as gone once its process is shown to have ended (#733).
118
122
  */
119
- export async function closeTabs(home, going, bots, name, again) {
123
+ export async function closeTabs(home, going, bots, name, again, { retiring = false } = {}) {
120
124
  const closed = [];
121
125
  for (const { name: session, tab } of going) {
122
126
  try {
@@ -125,12 +129,12 @@ export async function closeTabs(home, going, bots, name, again) {
125
129
  // A tab that would not close still holds its session, so nothing is
126
130
  // opened after this: two harnesses on one conversation is worse than a
127
131
  // restart that did not happen.
128
- throw new Error(alsoClosed(error.message, closed, bots, name));
132
+ throw new Error(alsoClosed(error.message, closed, bots, name, retiring ? again : undefined));
129
133
  }
130
134
  closed.push({ bot: name, name: session, tabId: tab.tabId, terminal: tab.handle });
131
135
  }
132
136
 
133
- await gone(home, closed, bots, name);
137
+ await gone(home, closed, bots, name, retiring ? { again, ptys: new Map(going.map(({ tab }) => [tab.tabId, tab.ptyId])) } : undefined);
134
138
  return closed;
135
139
  }
136
140
 
@@ -185,8 +189,21 @@ const ASK_MS = 100;
185
189
  * A listing that never catches up is not something the kit can put right, so it
186
190
  * stops and says which tab it is waiting on. The conversation is in the book,
187
191
  * which is the way back to it.
192
+ *
193
+ * A retire, given as `retire: { again, ptys }`, starts nothing in the tabs'
194
+ * place, and what it must not leave behind is a live harness (#733). Orca
195
+ * 1.4.255 can go on listing a closed tab for minutes, `connected: false`,
196
+ * after its process has ended: a pane its window still holds (tech notes,
197
+ * section 1). That row alone does not prove the process ended, since a pane
198
+ * can say it is not connected while its pty is live, and so Orca's own list
199
+ * of live ptys is read too. Once the wait is over, a tab of the retire's
200
+ * counts as gone when every row Orca lists for it says `connected: false`
201
+ * with the pty the kit closed, and Orca holds no process for that pty.
202
+ * Anything the kit cannot read is "cannot tell", and the retire stops as
203
+ * before, with the retire to run again: the user wants the session gone, not
204
+ * back.
188
205
  */
189
- async function gone(home, closed, bots, bot) {
206
+ async function gone(home, closed, bots, bot, retire) {
190
207
  // Nothing closed is nothing to wait for, and a bot that has never been up has
191
208
  // no Orca project to ask: Orca refuses the listing (tech notes, section 1).
192
209
  if (closed.length === 0) return;
@@ -194,18 +211,40 @@ async function gone(home, closed, bots, bot) {
194
211
  const ids = closed.map((tab) => tab.tabId);
195
212
 
196
213
  for (;;) {
197
- const there = new Set(tabs(home).map((tab) => tab.tabId));
198
- const left = ids.filter((id) => there.has(id));
214
+ const listed = tabs(home);
215
+ const there = new Set(listed.map((tab) => tab.tabId));
216
+ let left = ids.filter((id) => there.has(id));
199
217
  if (left.length === 0) return;
200
218
 
201
219
  if (Date.now() >= until) {
202
220
  const one = left.length === 1;
203
- throw new Error(`Orca answered the close for ${one ? 'this tab' : 'these tabs'} and is still listing ${one ? 'it' : 'them'} ${SETTLED_MS / 1000} seconds later: ${left.join(', ')}. Nothing was opened in ${one ? 'its' : 'their'} place, because a tab that is on the way out is not a tab to start a harness in. The conversations are in the book: ${shellWord(ownCli())} up --bots ${shellWord(bots)} --bot ${bot} brings them back once Orca has caught up.`);
221
+ if (retire === undefined) {
222
+ throw new Error(`Orca answered the close for ${one ? 'this tab' : 'these tabs'} and is still listing ${one ? 'it' : 'them'} ${SETTLED_MS / 1000} seconds later: ${left.join(', ')}. Nothing was opened in ${one ? 'its' : 'their'} place, because a tab that is on the way out is not a tab to start a harness in. The conversations are in the book: ${shellWord(ownCli())} up --bots ${shellWord(bots)} --bot ${bot} brings them back once Orca has caught up.`);
223
+ }
224
+ left = stillRunning(listed, left, retire.ptys);
225
+ if (left.length === 0) return;
226
+ const few = left.length === 1;
227
+ throw new Error(`Orca answered the close for ${few ? 'this tab' : 'these tabs'} and is still listing ${few ? 'it' : 'them'} ${SETTLED_MS / 1000} seconds later, and the kit cannot tell that ${few ? 'its harness has' : 'their harnesses have'} stopped: ${left.join(', ')}. The retire stopped there. Once Orca has caught up, run the same retire again: ${retire.again}`);
204
228
  }
205
229
  await pause(ASK_MS);
206
230
  }
207
231
  }
208
232
 
233
+ /**
234
+ * Of the tab ids `left`, the ones Orca's listing `listed` does not show to have
235
+ * ended: a tab ended when each row for it says `connected: false` with the pty
236
+ * `ptys` gives it, and `livePtys` reads and lacks that pty.
237
+ */
238
+ function stillRunning(listed, left, ptys) {
239
+ const live = livePtys();
240
+ if (live === undefined) return left;
241
+ return left.filter((id) => {
242
+ const pty = ptys.get(id);
243
+ const ended = (tab) => tab.connected === false && tab.ptyId === pty;
244
+ return typeof pty !== 'string' || live.has(pty) || !listed.filter((tab) => tab.tabId === id).every(ended);
245
+ });
246
+ }
247
+
209
248
  /**
210
249
  * A session the kit will not close a tab on: it is live, and the book does not
211
250
  * say which conversation is in it — a Codex hooks file trusted too late is
@@ -225,10 +264,17 @@ function cannotComeBack(bots, bot, name, was) {
225
264
  ].join(' ');
226
265
  }
227
266
 
228
- /** What a failed close owes whoever is reading: the tabs that went before it. */
229
- function alsoClosed(why, closed, bots, bot) {
267
+ /**
268
+ * What a failed close owes whoever is reading: the tabs that went before it.
269
+ * For a retire, given as the command to run again, the way on is that retire,
270
+ * not `up`: the user wants those sessions gone, not back (#733).
271
+ */
272
+ function alsoClosed(why, closed, bots, bot, retireAgain) {
230
273
  if (closed.length === 0) return why;
231
274
 
232
275
  const one = closed.length === 1;
276
+ if (retireAgain !== undefined) {
277
+ return `${why} ${closed.map((tab) => tab.name).join(', ')} ${one ? 'was' : 'were'} closed before it, and nothing more was retired. Once that tab can be closed, run the same retire again: ${retireAgain}`;
278
+ }
233
279
  return `${why} ${closed.map((tab) => tab.name).join(', ')} ${one ? 'was' : 'were'} closed before it and nothing was opened in ${one ? 'its' : 'their'} place: ${shellWord(ownCli())} up --bots ${shellWord(bots)} --bot ${bot} brings ${one ? 'it' : 'them'} back with ${one ? 'its' : 'their'} conversation.`;
234
280
  }
package/src/retire.js CHANGED
@@ -45,9 +45,11 @@ export const retiredDir = (bots) => path.join(bots, 'retired');
45
45
  * removed, and `processes` when it has a work dir: what `stopProcesses`
46
46
  * stopped and left of what it started there (#537). `retiredWith` holds one
47
47
  * `{ bot, session, maker, closed }` for each session that went with it,
48
- * deepest first, with its own `promptsLeft` and `processes`.
48
+ * deepest first, with its own `promptsLeft` and `processes`. `again` is the
49
+ * command the user ran, as they would run it again: what a refusal names, for
50
+ * this session and each that goes with it.
49
51
  */
50
- export async function retireSession(bots, { bot, session }) {
52
+ export async function retireSession(bots, { bot, session }, again = commandLine('retire', bots, bot, session)) {
51
53
  const home = fleetMember(bots, bot, 'retire', session);
52
54
  const known = readBot(home, bot);
53
55
  const sessions = sessionsOf(known, session);
@@ -59,11 +61,11 @@ export async function retireSession(bots, { bot, session }) {
59
61
  let processes;
60
62
  try {
61
63
  for (const { name } of made) {
62
- const { retiredWith: theirs, ...gone } = await retireSession(bots, { bot, session: name });
64
+ const { retiredWith: theirs, ...gone } = await retireSession(bots, { bot, session: name }, again);
63
65
  retiredWith.push(...theirs, { ...gone, maker: session });
64
66
  }
65
67
 
66
- closed = await closeTabs(home, tabsToClose(bots, bot, home, sessions, { keepless: true }), bots, bot, commandLine('retire', bots, bot, session));
68
+ closed = await closeTabs(home, tabsToClose(bots, bot, home, sessions, { keepless: true }), bots, bot, again, { retiring: true });
67
69
  // Only once its tab is closed: its harness starts nothing more.
68
70
  const dir = workDirOf(sessions[0], home);
69
71
  if (dir !== undefined) processes = await stopProcesses([{ session, dir }]);
@@ -137,7 +139,7 @@ export async function retireBot(bots, { bot }) {
137
139
  // taken away with one of them still open. `closeTabs` waits until Orca agrees
138
140
  // they are gone.
139
141
  const booked = Object.keys(readBook(home).sessions).map((name) => ({ name }));
140
- const closed = await closeTabs(home, tabsToClose(bots, bot, home, booked, { keepless: true }), bots, bot, commandLine('retire', bots, bot));
142
+ const closed = await closeTabs(home, tabsToClose(bots, bot, home, booked, { keepless: true }), bots, bot, commandLine('retire', bots, bot), { retiring: true });
141
143
  // Once their tabs are closed, and before the folder moves (#537).
142
144
  const dirs = known.sessions.flatMap((session) => {
143
145
  const dir = workDirOf(session, home);
package/src/temp.js CHANGED
@@ -367,7 +367,8 @@ const promptLeft = (why, written, left) =>
367
367
  export async function retireTemp(bots, { tab, name }) {
368
368
  const caller = callerIn(bots, tab, 'retire');
369
369
  ownTemp(caller, name, 'retire', 'Nothing was retired.');
370
- return { ...(await retireSession(bots, { bot: caller.bot, session: name })), maker: caller.session };
370
+ const again = `${shellWord(ownCli())} temp retire --bots ${shellWord(bots)} --name ${name}`;
371
+ return { ...(await retireSession(bots, { bot: caller.bot, session: name }, again)), maker: caller.session };
371
372
  }
372
373
 
373
374
  /**
package/src/up.js CHANGED
@@ -49,7 +49,7 @@ const SECOND_LOOK_MS = 2000;
49
49
  * one bot and `session` one of its sessions, for a caller that wants one thing
50
50
  * back rather than the fleet.
51
51
  */
52
- export async function bringUp(bots, { bot: onlyBot, session: onlySession } = {}) {
52
+ export async function bringUp(bots, { bot: onlyBot, session: onlySession, restarted } = {}) {
53
53
  const named = botsNamed(bots, onlyBot);
54
54
  if (onlySession !== undefined && onlyBot === undefined) {
55
55
  throw new Error('--session needs --bot: say which bot the session belongs to.');
@@ -72,7 +72,7 @@ export async function bringUp(bots, { bot: onlyBot, session: onlySession } = {})
72
72
  for (const session of sessionsOf(bot, onlySession)) {
73
73
  if (session.paused === true) paused.push({ bot: bot.name, session: session.name });
74
74
  }
75
- const up = await bringUpBot(bots, home, bot, onlySession);
75
+ const up = await bringUpBot(bots, home, bot, onlySession, restarted);
76
76
  report.push(...up.tabs);
77
77
  if (up.project !== undefined) projects.push(up.project);
78
78
  }
@@ -230,7 +230,7 @@ export function sessionsOf(bot, onlySession) {
230
230
  return named;
231
231
  }
232
232
 
233
- async function bringUpBot(bots, home, bot, onlySession) {
233
+ async function bringUpBot(bots, home, bot, onlySession, restarted) {
234
234
  const name = bot.name;
235
235
  const title = displayName(name);
236
236
  const sessions = awake(sessionsOf(bot, onlySession));
@@ -250,7 +250,7 @@ async function bringUpBot(bots, home, bot, onlySession) {
250
250
 
251
251
  const live = new Map(tabs(home).map((tab) => [tab.tabId, tab]));
252
252
  const report = [];
253
- for (const session of sessions) report.push(await bringUpSession(bots, home, live, session, bot, title));
253
+ for (const session of sessions) report.push(await bringUpSession(bots, home, live, session, bot, title, restarted?.has(session.name) === true));
254
254
 
255
255
  // The ops tab, and the whole of what the kit knows about it: Bot Father's
256
256
  // project needs one tab that is not a session, for work across the fleet. Any
@@ -274,7 +274,7 @@ async function bringUpBot(bots, home, bot, onlySession) {
274
274
  return { tabs: report, project: change === undefined && windowTrouble === undefined ? undefined : project };
275
275
  }
276
276
 
277
- async function bringUpSession(bots, home, live, session, bot, title) {
277
+ async function bringUpSession(bots, home, live, session, bot, title, restarted) {
278
278
  const harness = harnessOf(session, bot.harness);
279
279
  const book = readBook(home);
280
280
  const was = book.sessions[session.name];
@@ -431,11 +431,20 @@ async function bringUpSession(bots, home, live, session, bot, title) {
431
431
  ? undefined
432
432
  : await heldInRecord(home, session.name, harness, launch.prompt, { launched, running: tui.running === true });
433
433
 
434
+ // A session whose tab `obk restart` closed lost the background shells its
435
+ // harness ran there, and nothing else tells it (#726). So once it is resumed
436
+ // it is typed one line that says so, through the same gate, whatever its
437
+ // harness. For Codex that line is its first turn too, so it takes the place
438
+ // of the list line below.
439
+ const told = !restarted || launch.resume === undefined ? undefined
440
+ : tui.running === true ? await typeListLine(home, session.name, made.tabId, { line: restartLine(bots) })
441
+ : { typed: false, why: 'its harness did not come up in the new tab' };
442
+
434
443
  // A Codex session resumed here is not among Orca's agents until its first
435
444
  // turn: Codex fires its SessionStart then and not at the resume, seen live on
436
445
  // 0.157.1 (#226). So it is given one line, through the same gate as a mail
437
446
  // notice, which the owner allowed. A fresh start's first turn is its prompt.
438
- const list = harness === 'codex' && launch.resume !== undefined && tui.running === true
447
+ const list = told === undefined && harness === 'codex' && launch.resume !== undefined && tui.running === true
439
448
  ? await typeListLine(home, session.name, made.tabId, { line: listLine(bots) })
440
449
  : undefined;
441
450
 
@@ -456,6 +465,8 @@ async function bringUpSession(bots, home, live, session, bot, title) {
456
465
  unclaimed: which.unclaimed,
457
466
  listLine: list?.typed,
458
467
  listLineTrouble: list?.why,
468
+ restartLine: told?.typed,
469
+ restartLineTrouble: told?.why,
459
470
  });
460
471
  }
461
472
 
@@ -516,6 +527,25 @@ const listLine = (bots) => 'obk: this session was resumed in a new tab, and the
516
527
  + ` Read your mail with \`${shellWord(ownCli())} message check --bots ${shellWord(bots)}\`, and do what it asks.`
517
528
  + ' That includes mail you read just before this line. If there is none, reply "ok".';
518
529
 
530
+ /**
531
+ * The line a session is typed once `obk restart` has resumed it in a new tab,
532
+ * in the architect's ruling (#726). It says only what the kit knows: the old
533
+ * tab's harness ended, and its background shells with it. The kit cannot tell
534
+ * which shells ran, nor whether a scheduled job survived (one was seen to), so
535
+ * the session looks before it makes a job again. The mail part is the list
536
+ * line's as #709 left it, but for its last sentence: "If nothing needs doing"
537
+ * where the list line says "If there is none". This line also asks the session
538
+ * to start its shells again, and a session with no mail must not reply "ok"
539
+ * and stop there (the architect's ruling on PR #731).
540
+ */
541
+ const restartLine = (bots) => 'obk: obk restart closed this session\'s old tab and resumed the conversation in this new one.'
542
+ + ' Closing the old tab ended the harness there, and the background shells it ran ended with it.'
543
+ + ' The kit cannot tell which shells ran, or whether a scheduled job of the harness still runs.'
544
+ + ' Look at the harness\'s scheduled jobs before you make one again, so that only one of each runs.'
545
+ + ' Start again the shells you still need.'
546
+ + ` Read your mail with \`${shellWord(ownCli())} message check --bots ${shellWord(bots)}\`, and do what it asks.`
547
+ + ' That includes mail you read just before this line. If nothing needs doing, reply "ok".';
548
+
519
549
  /**
520
550
  * How long a resumed Codex tab is given to be one the kit may type into: live,
521
551
  * Orca named the agent 7 to 11 s after the restart (#226), and the gate waits
@@ -969,7 +999,7 @@ export const promptPath = (bots, bot, session) =>
969
999
  export const oldPromptPath = (bots, bot, session) =>
970
1000
  path.join(`${bots}.prompts`, `${encodeURIComponent(bot)}.${encodeURIComponent(session)}.txt`);
971
1001
 
972
- function entry(tab, { bot, name, created, running = false, blockedReason, promptReceived, promptFile, resumed, noConversation, harnessChanged, ranOn, unclaimed, noMailbox = false, listLine, listLineTrouble, notLaunched }) {
1002
+ function entry(tab, { bot, name, created, running = false, blockedReason, promptReceived, promptFile, resumed, noConversation, harnessChanged, ranOn, unclaimed, noMailbox = false, listLine, listLineTrouble, restartLine, restartLineTrouble, notLaunched }) {
973
1003
  const made = { bot, name, title: tab.title, tabId: tab.tabId, terminal: tab.handle, created, harnessStarted: running };
974
1004
  // A tab whose shell was not ready for the launch line, and why: nothing was
975
1005
  // typed into it (#498).
@@ -981,6 +1011,8 @@ function entry(tab, { bot, name, created, running = false, blockedReason, prompt
981
1011
  // and why not when it was not (#226).
982
1012
  if (listLine !== undefined) made.listLine = listLine;
983
1013
  if (listLineTrouble !== undefined) made.listLineTrouble = listLineTrouble;
1014
+ if (restartLine !== undefined) made.restartLine = restartLine;
1015
+ if (restartLineTrouble !== undefined) made.restartLineTrouble = restartLineTrouble;
984
1016
  // The id the book held that had no conversation behind it, now in the history.
985
1017
  if (noConversation !== undefined) made.noConversation = noConversation;
986
1018
  // The id the book held that ran on another harness, the one this session