@assuredloop/orca-bot-kit 0.27.0 → 0.28.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/src/cli.js CHANGED
@@ -12,7 +12,7 @@ import path from 'node:path';
12
12
  import { fileURLToPath } from 'node:url';
13
13
  import { parseArgs } from 'node:util';
14
14
 
15
- import { addSession, allowedNow, allowRules, botDir, changeBot, changeSession, createBot, disallowRules, leadsOutside, readBot, SESSION_FIELDS, setRoleCaps, setTempApproval } from './bot.js';
15
+ import { addSession, allowedNow, allowRules, botDir, changeBot, changeSession, createBot, disallowRules, leadsOutside, readBot, refuseCodexOutside, SESSION_FIELDS, setRoleCaps, setTempApproval } from './bot.js';
16
16
  import { COMMANDS } from './commands.js';
17
17
  import { addCommand, groomCommand, grooming, upCommand } from './groom.js';
18
18
  import { checkHealth, orcaSettingFindings } from './health.js';
@@ -21,7 +21,7 @@ import { nameSession } from './name.js';
21
21
  import { APPROVALS, harnessOf, HARNESSES, ownCli, refuseApprovalArgs, shellWord, workDirOf } from './launch.js';
22
22
  import { checkMail, lookUp, noMailboxYet, sendMessage, sessionInTab } from './message.js';
23
23
  import { orcaCli, orcaTrouble, RELOAD_LINE, TERMINAL_ENV } from './orca.js';
24
- import { allowIn, beyondDefaults, refuseBroad, refuseNoCodexForm, runsOnClaude, runsOnCodex, takeBack, writePermissions } from './permissions.js';
24
+ import { allowIn, beyondDefaults, botDefaults, refuseBroad, refuseNoCodexForm, runsOnClaude, runsOnCodex, takeBack, writePermissions } from './permissions.js';
25
25
  import { pauseSessions, unpauseSessions } from './pause.js';
26
26
  import { recordSession, SHELL_ENV, TAB_ENV } from './record.js';
27
27
  import { restartSessions } from './restart.js';
@@ -43,18 +43,19 @@ Usage:
43
43
  Create your bots folder: a git repo holding your
44
44
  bots' configuration, with Bot Father in it, and
45
45
  open Bot Father in Orca.
46
- obk bot create --bots <path> --name <bot> --harness claude|codex
46
+ obk bot create --bots <path> --name <bot> --harness claude|codex|pi
47
47
  [--charter <text>]
48
48
  Write a new bot in your bots folder. Nothing is
49
49
  opened in Orca until you run obk up.
50
50
  obk session add --bots <path> --bot <bot> --name <session>
51
- [--harness claude|codex] [--model <m>] [--effort <e>]
51
+ [--harness claude|codex|pi] [--model <m>] [--effort <e>]
52
52
  [--context <c>]
53
53
  [--prompt <text> | --prompt-file <path>] [--work-dir <path>]
54
54
  [--extra-arg=<arg>]
55
55
  Add a session to a bot. Anything left out is the
56
56
  harness's own default; approval is auto, and only
57
- obk permission approval changes it. A long
57
+ obk permission approval changes it. A session on
58
+ Pi is dangerously-skip, the one level Pi has. A long
58
59
  start prompt lives in a file in the bot home, and
59
60
  --prompt-file names it.
60
61
  A value of your own that starts with a dash is
@@ -87,15 +88,18 @@ Usage:
87
88
  the bot's temporary sessions may be made at. A
88
89
  running session takes it when it next starts.
89
90
  obk session change --bots <path> --bot <bot> --session <session>
91
+ [--harness claude|codex|pi]
90
92
  [--model <m>] [--effort <e>] [--context <c>]
91
93
  [--prompt <text> | --prompt-file <path>] [--work-dir <path>]
92
94
  [--extra-arg=<arg>]
93
95
  Change a session's settings. What you leave out
94
96
  stays as it is; a setting given empty, --model=,
95
97
  goes back to the harness's own default. A running
96
- session takes the change when it next starts. A
97
- session keeps its harness: to move it, retire it
98
- and add another.
98
+ session takes the change when it next starts. On
99
+ another harness it starts a new conversation, the
100
+ book keeps the old one in its history, and model,
101
+ effort and context go back to the new harness's
102
+ defaults unless given too.
99
103
  obk pause --bots <path> --bot <bot> [--session <name>]
100
104
  Stop a bot, or one of its sessions, for now: close
101
105
  its tabs and have obk up leave it closed. The book
@@ -219,6 +223,8 @@ Usage:
219
223
  folder and named in the message. A pair the
220
224
  harness's own messaging reaches is not carried:
221
225
  the address to write to is answered instead.
226
+ In a session's own tab it sends as that session,
227
+ and --from may name only that one.
222
228
  obk message check --bots <path> [--bot <bot>] [--session <name>] [--peek]
223
229
  Read what is waiting for a session and mark it
224
230
  read. --peek leaves it unread. Run in a session's
@@ -274,7 +280,8 @@ Usage:
274
280
  only while its tab is up in Orca. --run-on codex
275
281
  makes each run a temporary Codex session of the
276
282
  grooming session's, launched with --model, --effort
277
- and --extra-arg, which reports back by the kit's
283
+ and --extra-arg, which carries the grooming
284
+ session's own prompt, reports back by the kit's
278
285
  mail and is retired.
279
286
  obk usage --bots <path> [--bot <bot>] [--session <name>] [--since <time>] [--until <time>]
280
287
  Say what your sessions have used: the conversations
@@ -435,7 +442,7 @@ async function run(argv) {
435
442
  }
436
443
 
437
444
  // A permission rule narrows a command to one bots folder by its first words,
438
- // whatever comes after them (ADR 0036), and the last of two `--bots` would
445
+ // whatever comes after them (ADR 0050), and the last of two `--bots` would
439
446
  // win: so a second one is refused before anything runs.
440
447
  if (tokens.filter((token) => token.kind === 'option' && token.name === 'bots').length > 1) {
441
448
  throw new Error('--bots is given more than once. Give it once: one command works on one bots folder. Nothing was done.');
@@ -595,6 +602,8 @@ async function record(bots, bot) {
595
602
 
596
603
  const commands = {
597
604
  async init(bots, values) {
605
+ // Bot Father on Pi is not in the first slice of #547.
606
+ if (values.harness === 'pi') throw new Error('init does not take --harness pi yet: Bot Father runs on claude or codex, and its other bots may run on Pi. Nothing was written.');
598
607
  // Asked before anything is written, so an Orca that is down leaves the disk
599
608
  // exactly as it was and the caller can simply run the command again.
600
609
  refuseWhenOrcaIsDown();
@@ -827,7 +836,9 @@ const commands = {
827
836
  },
828
837
 
829
838
  'bot create'(bots, values) {
830
- const made = createBot(bots, { name: values.name, harness: values.harness, charter: values.charter });
839
+ // The kit's default rules (ADR 0050) are in the bot's first bot.yaml.
840
+ const allow = botDefaults(bots, { harness: values.harness, sessions: [] });
841
+ const made = createBot(bots, { name: values.name, harness: values.harness, charter: values.charter, allow });
831
842
  // The bot's AGENTS.md is the rules build's, here as everywhere else, so
832
843
  // that a new bot's file and a rebuilt one are written by the same code.
833
844
  const rules = [buildAgents(bots, made.home, readBot(made.home))];
@@ -838,9 +849,10 @@ const commands = {
838
849
  // instructions, so `up` will not start it, and saying "give it a session"
839
850
  // would send the caller past the thing that needs settling first.
840
851
  const trouble = rules[0].trouble !== undefined;
841
- // The kit's default rules (ADR 0036), for a bot that is set up to run: one
842
- // with no instructions gets them at the rules build that gives it some.
843
- const permissions = trouble ? [] : writePermissions(bots, made.home, readBot(made.home));
852
+ // The default rules go into the harness's files for a bot that is set up
853
+ // to run: one with no instructions gets them at the rules build that gives
854
+ // it some. They were added by this command, so its report names them.
855
+ const permissions = trouble ? [] : writePermissions(bots, made.home, readBot(made.home)).map((entry) => ({ ...entry, defaults: allow }));
844
856
  const answer = { bots, bot: made.bot, home: made.home, created: made.created, rules, skills, permissions };
845
857
  return {
846
858
  answer,
@@ -862,7 +874,7 @@ const commands = {
862
874
  throw new Error(`bot change does not change a bot's harness: its sessions' conversations belong to the harness they ran on. To move to ${values.harness}, give it a session on ${values.harness} with obk session add, or retire the bot with obk retire and create a new one.`);
863
875
  }
864
876
  // Permission changes have commands of their own, which keep the user's yes
865
- // (ADR 0041): the old spelling refuses, and changes nothing.
877
+ // (ADR 0051): the old spelling refuses, and changes nothing.
866
878
  const moved = [['allow', 'permission allow'], ['disallow', 'permission disallow']].filter(([flag]) => values[flag] !== undefined);
867
879
  if (moved.length > 0) {
868
880
  throw new Error(`bot change no longer takes ${moved.map(([flag]) => `--${flag}`).join(' or ')}: permission rules change through ${moved.map(([, now]) => `${shellWord(ownCli())} ${now} --bots ${shellWord(bots)} --bot ${values.bot} --rule <rule>`).join(' and ')}, after the user's yes. Nothing was changed.`);
@@ -1069,7 +1081,7 @@ const commands = {
1069
1081
  ...(values.effort === undefined ? {} : { effort: values.effort }),
1070
1082
  ...(values['extra-arg'] === undefined ? {} : { extra_args: values['extra-arg'] }),
1071
1083
  };
1072
- // A run's launch may not widen its approval (ADR 0041).
1084
+ // A run's launch may not widen its approval (ADR 0051).
1073
1085
  if (values['run-on'] === 'codex') refuseApprovalArgs('codex', values['extra-arg']);
1074
1086
  const groom = grooming(bots, { at: values.at, ask, run });
1075
1087
  return { answer: { bots, groom }, lines: groomLines(groom, bots) };
@@ -1132,7 +1144,8 @@ const commands = {
1132
1144
  },
1133
1145
 
1134
1146
  async 'message send'(bots, values) {
1135
- const tab = callerTab(bots, values.from === undefined);
1147
+ // With --from too: in one of the fleet's tabs, the tab says who sends (#570).
1148
+ const tab = callerTab(bots, true);
1136
1149
  refuseWhenOrcaIsDown();
1137
1150
  const answer = await sendMessage(bots, {
1138
1151
  to: values.to,
@@ -1204,6 +1217,7 @@ const commands = {
1204
1217
 
1205
1218
  'session add'(bots, values) {
1206
1219
  refuseApproval(bots, values, 'session add');
1220
+ refuseOutsideWorkDir(bots, values);
1207
1221
  const added = addSession(bots, values.bot, settingsOf(values));
1208
1222
  const found = workDirFound(values, added.bot, added.home, added.session.name);
1209
1223
  const answer = { bots, bot: added.bot, home: added.home, session: added.session, found };
@@ -1233,21 +1247,23 @@ const commands = {
1233
1247
  },
1234
1248
 
1235
1249
  'session change'(bots, values) {
1236
- if (values.harness !== undefined) {
1237
- throw new Error(`session change does not change a session's harness: its conversations belong to the harness they ran on. To move it, retire it with obk retire and add one on ${values.harness} with obk session add.`);
1238
- }
1239
1250
  refuseApproval(bots, values, 'session change');
1240
1251
  const { name, ...settings } = settingsOf(values);
1241
1252
  const changed = changeSession(bots, values.bot, values.session, settings);
1242
1253
  const restart = `${shellWord(ownCli())} restart --bots ${shellWord(bots)} --bot ${changed.bot} --session ${values.session}`;
1243
1254
  const found = workDirFound(values, changed.bot, changed.home, values.session);
1255
+ const { moved } = changed;
1244
1256
  return {
1245
- answer: { bots, bot: changed.bot, home: changed.home, session: changed.session, restart, found },
1257
+ answer: { bots, bot: changed.bot, home: changed.home, session: changed.session, ...(moved === undefined ? {} : { moved }), restart, found },
1246
1258
  lines: [
1247
1259
  `changed session ${values.session} in ${path.join('bots', changed.bot, 'bot.yaml')}`,
1248
1260
  ...Object.entries(changed.session)
1249
1261
  .filter(([key]) => key !== 'name')
1250
1262
  .map(([key, value]) => ` ${key} ${oneLine(value)}`),
1263
+ ...(moved === undefined ? [] : [
1264
+ `moved from ${moved.from} to ${moved.to}: when it next starts, it starts a new conversation on ${moved.to}, and the book keeps its old one in the session's history.`,
1265
+ ...(moved.defaults.length === 0 ? [] : [` ${moved.defaults.join(', ')} went back to ${moved.to}'s own default${moved.defaults.length === 1 ? '' : 's'}.`]),
1266
+ ]),
1251
1267
  ...foundLines(found),
1252
1268
  `A running session takes this when it next starts: ${restart}`,
1253
1269
  ],
@@ -1290,7 +1306,7 @@ function toLines(answer, bots, where) {
1290
1306
  // says what to do instead, before the bot needs it (#521).
1291
1307
  return [
1292
1308
  ` Write to ${answer.address} with your own harness's messaging. The kit does not carry that road.`,
1293
- " If your harness refuses that call, do not send the message again by another road. Tell your maker or your user that it was refused, with the harness's reason. A refusal can mean a missing permission rule, which they can add through the kit after the user's yes.",
1309
+ " If your harness refuses that call, do not send the message again by another road. Tell your maker or your user that it was refused, with the harness's reason. A refusal can mean a missing permission rule. Send the refusal to whoever adds your rules: your maker, your user or your fleet's manager.",
1294
1310
  ];
1295
1311
  }
1296
1312
  return [
@@ -1306,25 +1322,45 @@ function toLines(answer, bots, where) {
1306
1322
  * came to when one was asked for (#555).
1307
1323
  */
1308
1324
  function sentLines(answer, where) {
1309
- const road = " it is in its Orca mailbox, and Orca's own notice tells it to look once it is idle.";
1325
+ const road = noticeLines(answer);
1310
1326
  if (answer.interrupted === true) {
1311
1327
  return [
1312
- `${road.replace('once it is idle', 'now')}`,
1328
+ ...(road.length === 1 ? [road[0].replace('once it is idle', 'now')] : road),
1313
1329
  ` it was busy, so the kit pressed Escape once in its tab to end its turn.${answer.to.harness === 'codex' ? ' Codex keeps a command it was running as a background terminal after an Escape.' : ''}`,
1314
1330
  ];
1315
1331
  }
1316
1332
  if (answer.interrupted === false) {
1317
- return [road, answer.idle ? ` it is idle, so it was not interrupted.` : ` it was not interrupted: ${answer.interruptTrouble}.`];
1333
+ return [...road, answer.idle ? ` it is idle, so it was not interrupted.` : ` it was not interrupted: ${answer.interruptTrouble}.`];
1318
1334
  }
1319
- return [road];
1335
+ return road;
1336
+ }
1337
+
1338
+ /**
1339
+ * Whether Orca's notice will tell the receiver of its letter. It will not while
1340
+ * Orca lists the receiver's tab as orphaned, as after a window restart: the
1341
+ * window has not loaded the tab (#579). A switch to the tab loads it, and is
1342
+ * named as a fact with its cost, not run and not urged: it moves the user's view.
1343
+ */
1344
+ function noticeLines(answer) {
1345
+ if (answer.orphaned === true) {
1346
+ return [
1347
+ " it is in its Orca mailbox, and it waits there: Orca lists its tab as orphaned, because the Orca window has not loaded that tab, and Orca will not tell it of the letter until the window loads it.",
1348
+ ` ${shellWord(orcaCli())} terminal switch --terminal ${shellWord(answer.handle)} loads the tab. It also moves the user's view to that tab, so what the user types can land in it.`,
1349
+ ];
1350
+ }
1351
+ if (answer.loadTrouble !== undefined) {
1352
+ return [` it is in its Orca mailbox. The kit could not tell whether the Orca window has loaded its tab (${answer.loadTrouble}), and Orca tells it of the letter only once the window has loaded the tab.`];
1353
+ }
1354
+ return [" it is in its Orca mailbox, and Orca's own notice tells it to look once it is idle."];
1320
1355
  }
1321
1356
 
1322
1357
  /**
1323
1358
  * What there is to say about the work dir this command was given: one finding
1324
1359
  * when it leads out of the bot home, through `..` or a link or as a path
1325
1360
  * elsewhere, and nothing otherwise (#222). A bot's work and its clones belong
1326
- * under its own `work/`. It is said, not refused: the user may ask for a folder
1327
- * anywhere (PRD 6.3), and it is written as they gave it.
1361
+ * under its own `work/`. For a Claude or Pi session it is said, not refused: a
1362
+ * work dir is an instruction there (PRD 6.4), and it is written as given. A
1363
+ * Codex session's is refused before this (refuseCodexOutside).
1328
1364
  */
1329
1365
  function workDirFound(values, bot, home, session) {
1330
1366
  const workDir = workDirOf({ work_dir: values['work-dir'] }, home);
@@ -1336,16 +1372,35 @@ function workDirFound(values, bot, home, session) {
1336
1372
  }];
1337
1373
  }
1338
1374
 
1375
+ /**
1376
+ * Refuse a new Codex session a work dir outside its bot home (ADR 0045). A
1377
+ * changed session is judged in changeSession, on what it writes (#585).
1378
+ */
1379
+ function refuseOutsideWorkDir(bots, values) {
1380
+ if (values['work-dir'] === undefined) return;
1381
+ let bot;
1382
+ try {
1383
+ bot = readBot(botDir(bots, values.bot), values.bot);
1384
+ } catch {
1385
+ // A bot that is not there is the command's own to refuse.
1386
+ return;
1387
+ }
1388
+ const session = { name: values.name, harness: values.harness, work_dir: values['work-dir'] };
1389
+ refuseCodexOutside(values.bot, session, undefined, bot.harness, botDir(bots, values.bot), 'session add');
1390
+ }
1391
+
1339
1392
  /**
1340
1393
  * Refuse what would change a session's approval through `session add` or
1341
- * `session change` (ADR 0041): `--approval`, and an extra argument that sets
1394
+ * `session change` (ADR 0051): `--approval`, and an extra argument that sets
1342
1395
  * it on the session's harness. Only `permission approval` changes it.
1343
1396
  */
1344
1397
  function refuseApproval(bots, values, command) {
1345
1398
  if (values.approval !== undefined) {
1346
1399
  throw new Error(`${command} no longer takes --approval: a session's approval changes through ${shellWord(ownCli())} permission approval --bots ${shellWord(bots)} --bot ${values.bot} --session ${values.session ?? values.name} --approval <level>, after the user's yes. Nothing was changed.`);
1347
1400
  }
1348
- if (values['extra-arg'] === undefined) return;
1401
+ // A move to another harness keeps the extra arguments the session has, and
1402
+ // one of them can set approval on the new harness (#565).
1403
+ if (values['extra-arg'] === undefined && values.harness === undefined) return;
1349
1404
  let bot;
1350
1405
  try {
1351
1406
  bot = readBot(botDir(bots, values.bot), values.bot);
@@ -1354,7 +1409,7 @@ function refuseApproval(bots, values, command) {
1354
1409
  return;
1355
1410
  }
1356
1411
  const session = bot.sessions.find((one) => one.name === (values.session ?? values.name)) ?? {};
1357
- refuseApprovalArgs(harnessOf({ ...session, ...(values.harness === undefined ? {} : { harness: values.harness }) }, bot.harness), values['extra-arg']);
1412
+ refuseApprovalArgs(harnessOf({ ...session, ...(values.harness === undefined ? {} : { harness: values.harness }) }, bot.harness), values['extra-arg'] ?? (command === 'session change' ? session.extra_args : undefined));
1358
1413
  }
1359
1414
 
1360
1415
  /**
@@ -1437,6 +1492,12 @@ function refuseWhenOrcaIsDown() {
1437
1492
  /** The one line that says which conversation this tab was given, and from where. */
1438
1493
  const howLines = (tab) => {
1439
1494
  if (tab.resumed === true) return [' it was told to resume the session the book holds, with its conversation.'];
1495
+ if (tab.harnessChanged !== undefined) {
1496
+ return [
1497
+ ` it was told to start a new session: the book's conversation ${tab.harnessChanged} ran on ${tab.ranOn},`,
1498
+ " and the session has moved from it, so it went into the session's history.",
1499
+ ];
1500
+ }
1440
1501
  if (tab.noConversation === undefined) return [' it was told to start a new session: the book holds none for this one yet.'];
1441
1502
  return [
1442
1503
  ` it was told to start a new session: the book's conversation ${tab.noConversation} had nothing on record`,
@@ -1658,7 +1719,7 @@ function rosterLines(roster, bots) {
1658
1719
  }
1659
1720
 
1660
1721
  lines.push(roster.length === 0
1661
- ? `No bots yet. Make one: ${shellWord(ownCli())} bot create --bots ${shellWord(bots)} --name <name> --harness claude|codex`
1722
+ ? `No bots yet. Make one: ${shellWord(ownCli())} bot create --bots ${shellWord(bots)} --name <name> --harness claude|codex|pi`
1662
1723
  : `${roster.length} bot${roster.length === 1 ? '' : 's'}. Your bots folder: ${bots}`);
1663
1724
  return lines;
1664
1725
  }
@@ -1725,9 +1786,9 @@ function charterRulesLines(bots, bot, rules) {
1725
1786
 
1726
1787
  /**
1727
1788
  * What became of each bot's permission rules: each of the kit's default rules
1728
- * the run added to the bot, word for word (ADR 0036), each rule of a command no
1789
+ * the run added to the bot, word for word (ADR 0050), each rule of a command no
1729
1790
  * longer in the set that the bot still holds, with the command that takes it
1730
- * back (ADR 0041), and the file the allowed ones were written into. Nothing for
1791
+ * back (ADR 0051), and the file the allowed ones were written into. Nothing for
1731
1792
  * a bot with nothing added, held or written.
1732
1793
  */
1733
1794
  function permissionsLines(permissions, bots) {
package/src/commands.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // The kit's commands, each with the flags it cannot do without, which `obk`
2
2
  // reads to parse a command line; and the two lists every command is on one of:
3
- // those every bot is allowed by default (#527, ADR 0036), and those kept back
4
- // for the user's yes (ADR 0041). A new command is on neither until someone
3
+ // those every bot is allowed by default (#527, ADR 0050), and those kept back
4
+ // for the user's yes (ADR 0051). A new command is on neither until someone
5
5
  // puts it on one, and a test holds every command to that choice.
6
6
 
7
7
  /** The commands, and the flags each one cannot do without. */
@@ -50,14 +50,14 @@ export const COMMANDS = {
50
50
 
51
51
  /**
52
52
  * The commands every bot is allowed by default, each narrowed to its bots
53
- * folder (ADR 0036). Only a command named here is in the default set; one that
53
+ * folder (ADR 0050). Only a command named here is in the default set; one that
54
54
  * runs whatever its arguments say never is.
55
55
  */
56
56
  export const DEFAULT_COMMANDS = [
57
57
  'up', 'unpause', 'health', 'groom', 'roster', 'usage',
58
- 'bot change', 'rules build',
58
+ 'rules build',
59
59
  'skills add', 'skills remove', 'skills build', 'skills fetch', 'skills update', 'source add',
60
- 'session add', 'session change', 'session clear', 'session compact',
60
+ 'session clear', 'session compact',
61
61
  'message to', 'message send', 'message check',
62
62
  'session record', 'session sent', 'session name', 'session mailbox',
63
63
  'session trust-hooks', 'session answer',
@@ -66,17 +66,26 @@ export const DEFAULT_COMMANDS = [
66
66
 
67
67
  /**
68
68
  * The commands kept out of the default permission set, which keep the user's
69
- * yes (ADR 0041): the ones that change permissions, close a long-lived
70
- * session, make the bots folder, or make a bot, which the owner leaves to Bot
71
- * Father (#548).
69
+ * yes (ADR 0051): the ones that change permissions, close a long-lived
70
+ * session, make the bots folder, or make or change a bot or its sessions,
71
+ * which the owner leaves to Bot Father (#548, #615).
72
72
  */
73
73
  export const KEPT_BACK = [
74
74
  'init', 'retire', 'pause', 'restart', 'bot create',
75
+ 'bot change', 'session add', 'session change',
75
76
  'permission allow', 'permission disallow', 'permission approval',
76
77
  ];
77
78
 
78
79
  /**
79
- * The commands kit 0.26.0 gave every bot by default and that are kept back now
80
- * (#548). A bot may still hold their rules, which the kit names and leaves.
80
+ * The commands the kit gave every bot by default and that are kept back now
81
+ * (#548, #615). A bot may still hold their rules, which the kit names and
82
+ * leaves.
81
83
  */
82
- export const NO_LONGER_DEFAULT = ['restart', 'bot create'];
84
+ export const NO_LONGER_DEFAULT = ['restart', 'bot create', 'bot change', 'session add', 'session change'];
85
+
86
+ /**
87
+ * The commands kit 0.26.0 gave every bot by default and that the kit no longer
88
+ * has (#555). A bot may still hold their rules, which the kit names and leaves
89
+ * (#564).
90
+ */
91
+ export const REMOVED = ['session mail', 'session nudge'];
@@ -6,11 +6,11 @@
6
6
  // the kit never recorded must not be mistaken for a session that never had one,
7
7
  // so when the book cannot say, the harness is asked instead.
8
8
  //
9
- // Both harnesses keep their own record of every conversation, per working
9
+ // Every harness keeps its own record of every conversation, per working
10
10
  // directory, and every session starts at its bot home. Read only, never
11
11
  // written: these are the harness's files (tech notes, sections 2 and 3).
12
12
 
13
- import { existsSync, readdirSync, statSync } from 'node:fs';
13
+ import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs';
14
14
  import { homedir } from 'node:os';
15
15
  import path from 'node:path';
16
16
 
@@ -22,6 +22,38 @@ const claudeDir = (home) => path.join(homedir(), '.claude', 'projects', slug(hom
22
22
  /** The folder's name is the working directory with everything else turned into a dash. */
23
23
  const slug = (home) => home.replaceAll(/[^A-Za-z0-9]/g, '-');
24
24
 
25
+ /**
26
+ * Pi's sessions: one folder per working directory, named from its real path
27
+ * with the leading `/` taken off and `/`, `\` and `:` turned into dashes,
28
+ * between `--`; one `<time>_<id>.jsonl` per conversation (Pi 1.1.0,
29
+ * `getDefaultSessionDirPath`, tech notes, section 6).
30
+ */
31
+ const piDir = (home) => path.join(homedir(), '.pi', 'agent', 'sessions', `--${realHome(home).replace(/^[/\\]/, '').replaceAll(/[/\\:]/g, '-')}--`);
32
+
33
+ /** The folder as Pi sees it from inside: the real path, when there is one. */
34
+ function realHome(home) {
35
+ try {
36
+ return realpathSync(home);
37
+ } catch {
38
+ return home;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * A Pi session file's name, `<time>_<id>.jsonl`: the time is Pi's ISO time
44
+ * with `:` and `.` turned into dashes, and the id may hold a `_` of its own.
45
+ */
46
+ const PI_FILE = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z_(.+)\.jsonl$/;
47
+
48
+ /** The id a Pi session file's name carries, or undefined for a file that is not one. */
49
+ const piIdOf = (name) => PI_FILE.exec(name)?.[1];
50
+
51
+ /** Where Pi keeps the conversation `id` of this bot home, or undefined when it has none yet. */
52
+ const piSession = (home, id) => {
53
+ const name = files(piDir(home)).find((one) => piIdOf(one) === id);
54
+ return name === undefined ? undefined : path.join(piDir(home), name);
55
+ };
56
+
25
57
  /** Codex's rollouts: a file per conversation, filed by the day it started. */
26
58
  const codexDir = () => path.join(homedir(), '.codex', 'sessions');
27
59
 
@@ -52,6 +84,7 @@ export function conversationsIn(harness, home, since) {
52
84
  */
53
85
  export function hasConversation(harness, home, id) {
54
86
  if (harness === 'claude') return existsSync(claudeTranscript(home, id));
87
+ if (harness === 'pi') return piSession(home, id) !== undefined;
55
88
  return codexRollout(id) !== undefined;
56
89
  }
57
90
 
@@ -120,7 +153,9 @@ export function claudeSubagentTranscripts(file) {
120
153
  */
121
154
  export function transcriptsIn(harness, home, since) {
122
155
  const from = since === undefined ? 0 : Date.parse(since);
123
- const found = harness === 'claude' ? claudeConversations(home) : codexConversations(home, from);
156
+ const found = harness === 'claude' ? claudeConversations(home)
157
+ : harness === 'pi' ? piConversations(home)
158
+ : codexConversations(home, from);
124
159
  return found
125
160
  .filter((one) => Number.isNaN(from) || one.at >= from)
126
161
  .sort((left, right) => left.at - right.at)
@@ -173,6 +208,14 @@ function userTexts(harness, line) {
173
208
  if (typeof content === 'string') return [content];
174
209
  return Array.isArray(content) ? content.filter((block) => block?.type === 'text').map((block) => String(block.text)) : [];
175
210
  }
211
+ if (harness === 'pi') {
212
+ // A turn is a `message` line, and the user's are role `user` (Pi 1.1.0,
213
+ // docs/session-format.md).
214
+ const message = parsed?.type === 'message' ? parsed.message : undefined;
215
+ if (message?.role !== 'user') return [];
216
+ if (typeof message.content === 'string') return [message.content];
217
+ return Array.isArray(message.content) ? message.content.filter((block) => block?.type === 'text').map((block) => String(block.text)) : [];
218
+ }
176
219
  const item = parsed?.type === 'response_item' ? parsed.payload : undefined;
177
220
  if (item?.type !== 'message' || item.role !== 'user' || !Array.isArray(item.content)) return [];
178
221
  return item.content.filter((block) => block?.type === 'input_text').map((block) => String(block.text));
@@ -191,6 +234,33 @@ function claudeConversations(home) {
191
234
  });
192
235
  }
193
236
 
237
+ /**
238
+ * Pi's conversations in this bot home. The first line of each is its header,
239
+ * `{ type: 'session', id, timestamp, cwd }`, and says which conversation it is
240
+ * and when it began; the file's name says the id too.
241
+ */
242
+ function piConversations(home) {
243
+ return files(piDir(home))
244
+ .filter((name) => name.endsWith('.jsonl'))
245
+ .flatMap((name) => {
246
+ const file = path.join(piDir(home), name);
247
+ const header = piHeader(file);
248
+ const id = typeof header?.id === 'string' ? header.id : piIdOf(name);
249
+ const at = Date.parse(header?.timestamp ?? '') || startedAt(file);
250
+ return at === undefined || id === undefined || id === '' ? [] : [{ id, at, file }];
251
+ });
252
+ }
253
+
254
+ /** A Pi session's first line, when it is the header. */
255
+ function piHeader(file) {
256
+ try {
257
+ const parsed = JSON.parse(firstLine(file) ?? '');
258
+ return parsed?.type === 'session' ? parsed : undefined;
259
+ } catch {
260
+ return undefined;
261
+ }
262
+ }
263
+
194
264
  /** When a transcript's first line says the conversation was, if it says at all. */
195
265
  function said(file) {
196
266
  try {
package/src/groom.js CHANGED
@@ -44,7 +44,7 @@ import path from 'node:path';
44
44
  import { botDir, readBot, requireBotsFolder } from './bot.js';
45
45
  import { readBook, takeLineTurn, TYPING_HELD, TYPING_WAIT_MS } from './book.js';
46
46
  import { claudeTranscript } from './conversations.js';
47
- import { harnessOf, ownCli, shellWord } from './launch.js';
47
+ import { harnessOf, ownCli, promptFile, shellWord, startPrompt } from './launch.js';
48
48
  import { tabs, tabToTypeInto, typeIntoTab } from './orca.js';
49
49
  import { BOT_FATHER, promptPath } from './up.js';
50
50
  import { lines } from './usage.js';
@@ -123,7 +123,7 @@ export function grooming(bots, { at, ask, run } = {}) {
123
123
  ? groomingJobs(ofThisProcess(home, entry), markerOf(bots), Date.now())
124
124
  : [];
125
125
 
126
- const line = ask === undefined ? undefined : lineFor(ask, { bots, session, jobs, at, run });
126
+ const line = ask === undefined ? undefined : lineFor(ask, { bots, home, found, session, jobs, at, run });
127
127
  if (line !== undefined) typeInto(bots, home, session, tab, line);
128
128
 
129
129
  // A move is turning it on at another time, and is typed as one.
@@ -135,7 +135,7 @@ export function grooming(bots, { at, ask, run } = {}) {
135
135
  * nothing to ask. Everything that makes the ask impossible refuses here, before
136
136
  * Orca is asked for anything.
137
137
  */
138
- function lineFor(ask, { bots, session, jobs, at, run }) {
138
+ function lineFor(ask, { bots, home, found, session, jobs, at, run }) {
139
139
  // Nothing to turn off is an answer, unless a session is there to be told: an
140
140
  // `--on` typed a moment ago may still be waiting in its tab.
141
141
  if (ask === 'off' && jobs.length === 0 && !(session?.harness === 'claude' && session.up)) return undefined;
@@ -165,7 +165,8 @@ function lineFor(ask, { bots, session, jobs, at, run }) {
165
165
  // A move keeps what the runs are on, when every job there says the same.
166
166
  const runs = [...new Set(jobs.map((job) => JSON.stringify(job.run ?? null)))];
167
167
  const keep = ask === 'move' && runs.length === 1 ? JSON.parse(runs[0]) ?? undefined : undefined;
168
- return onLine(bots, cronOf(when), jobs, run ?? keep);
168
+ const onRun = run ?? keep;
169
+ return onLine(bots, cronOf(when), jobs, onRun === undefined ? undefined : { ...onRun, task: writeRunTask(bots, ownPrompt(home, found)) });
169
170
  }
170
171
 
171
172
  /**
@@ -367,7 +368,7 @@ function runElsewhere(bots, onRun) {
367
368
  ...(onRun.model === undefined ? [] : [`--model ${shellWord(onRun.model)}`]),
368
369
  ...(onRun.effort === undefined ? [] : [`--effort ${shellWord(onRun.effort)}`]),
369
370
  ...(onRun.extra_args ?? []).map((arg) => `--extra-arg=${shellWord(arg)}`),
370
- `--prompt-file ${shellWord(writeRunTask(bots))}`,
371
+ `--prompt-file ${shellWord(onRun.task)}`,
371
372
  ].join(' ');
372
373
  return `${markerOf(bots)}: the daily grooming for this fleet, run on ${onRun.harness} as a temporary session of yours `
373
374
  + `that does the work and reports back to you. First, if any of your temporary sessions whose names start with groom- `
@@ -390,13 +391,29 @@ function readReport(bots) {
390
391
  + `Then retire the run with ${retire}, its name in place of ${RUN_NAME}. Schedule nothing then: the job renewed itself when it fired.`;
391
392
  }
392
393
 
394
+ /**
395
+ * The grooming session's own prompt, as the user wrote it, or undefined when it
396
+ * has none: the standing duties a run on Codex carries too (#566). Without the
397
+ * work-dir note the session is launched with, since a run has a work dir of its
398
+ * own. Read when the line is typed, so a prompt changed since reaches the runs
399
+ * at the next `--on`.
400
+ */
401
+ function ownPrompt(home, session) {
402
+ try {
403
+ return startPrompt(session, { home });
404
+ } catch (error) {
405
+ throw new Error(`${BOT_FATHER}'s ${GROOMING} session's prompt file ${promptFile(session, home)} cannot be read (${error.code ?? error.message}), and each grooming run on Codex carries that prompt. Nothing was typed. Put the file back, or change the session's prompt, then run this again.`);
406
+ }
407
+ }
408
+
393
409
  /**
394
410
  * Write the task a Codex run is started with, and answer where: do the daily
395
- * grooming with the obk-grooming skill, send the report to the grooming session
396
- * by the kit's mail, and stop. It schedules nothing: the schedule is the
397
- * grooming session's.
411
+ * grooming with the obk-grooming skill, and the grooming session's own duties
412
+ * when its prompt has any, send the report to the grooming session by the
413
+ * kit's mail, and stop. It schedules nothing: the schedule is the grooming
414
+ * session's.
398
415
  */
399
- function writeRunTask(bots) {
416
+ function writeRunTask(bots, own) {
400
417
  const real = realHome(bots);
401
418
  const kit = shellWord(ownCli());
402
419
  const file = path.resolve(promptPath(real, BOT_FATHER, `${GROOMING}-run`));
@@ -405,6 +422,7 @@ function writeRunTask(bots) {
405
422
  + `since the last run's end up to it, counting with ${kit} usage --since <last end> --until <this end>, and write this end down for the next run. `
406
423
  + 'Work out what it cost with obk-finops, and keep each bot\'s profile notes. '
407
424
  + `The kit here is ${kit}: run it wherever a skill says obk. The bots folder is ${real}. `
425
+ + (own === undefined ? '' : `The ${GROOMING} session's own prompt is this run's too. It follows, word for word as the user wrote it:\n\n${own}\n\n`)
408
426
  + `When the grooming is done, send one short report to ${BOT_FATHER}/${GROOMING} by the kit's mail: `
409
427
  + `ask for the road with ${kit} message to --bots ${shellWord(real)} --to ${BOT_FATHER}/${GROOMING}, then send it with the message send command its answer names. `
410
428
  + 'Then stop: schedule nothing, make no session, and say nothing more.\n';