openmausbot 0.1.69 → 0.1.71

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.
Files changed (66) hide show
  1. package/dist/assets/{index-BmHupgVJ.js → index-CJtyBonr.js} +1 -1
  2. package/dist/assets/index-CYLdYtYp.css +1 -0
  3. package/dist/assets/index-Di4ovwu5.js +289 -0
  4. package/dist/index.html +2 -2
  5. package/dist-server/container-mcp.js +6 -2
  6. package/dist-server/drivers/agents-proxy.js +305 -16
  7. package/dist-server/drivers/pi-mcp-extension.ts +1 -1
  8. package/dist-server/index.js +3141 -1124
  9. package/dist-server/local-computer-proxy.js +2 -0
  10. package/dist-server/local-computer.js +2 -1
  11. package/dist-server/mcp-gate.js +543 -0
  12. package/dist-server/mcp-server.js +38 -0
  13. package/dist-server/openmausbot.js +772 -260
  14. package/dist-server/pair-cli.js +772 -260
  15. package/dist-server/prepare-cloudflared.js +20 -3
  16. package/dist-server/proxy-paths.js +1 -0
  17. package/dist-server/server/agent-tool-policy.js +29 -0
  18. package/dist-server/server/bot-package.js +55 -2
  19. package/dist-server/server/browser-live.js +12 -7
  20. package/dist-server/server/config.js +40 -4
  21. package/dist-server/server/delegations.js +132 -74
  22. package/dist-server/server/drivers/acp/core.js +2 -0
  23. package/dist-server/server/drivers/acp/cursor.js +1 -1
  24. package/dist-server/server/drivers/acp/grok.js +5 -1
  25. package/dist-server/server/drivers/agents-proxy.js +324 -16
  26. package/dist-server/server/drivers/boxagent.js +1 -1
  27. package/dist-server/server/drivers/claude-login-auth.js +102 -9
  28. package/dist-server/server/drivers/claude.js +298 -21
  29. package/dist-server/server/drivers/codex.js +13 -4
  30. package/dist-server/server/drivers/pi.js +1 -1
  31. package/dist-server/server/engine-install.js +137 -0
  32. package/dist-server/server/env-path.js +12 -0
  33. package/dist-server/server/harness/bus.js +1 -1
  34. package/dist-server/server/harness/registry.js +31 -6
  35. package/dist-server/server/index.js +860 -215
  36. package/dist-server/server/local-computer.js +1 -1
  37. package/dist-server/server/mcp-gate-config.js +56 -0
  38. package/dist-server/server/mcp-gate.js +227 -0
  39. package/dist-server/server/mcp-trim.js +182 -0
  40. package/dist-server/server/memory-journal.js +440 -0
  41. package/dist-server/server/memory-store.js +308 -0
  42. package/dist-server/server/message-db.js +90 -3
  43. package/dist-server/server/package-export.js +25 -0
  44. package/dist-server/server/peer-approval.js +1 -1
  45. package/dist-server/server/peer-provenance.js +6 -2
  46. package/dist-server/server/proxy-paths.js +1 -0
  47. package/dist-server/server/redact.js +15 -0
  48. package/dist-server/server/resume-recovery.js +46 -0
  49. package/dist-server/server/routines.js +116 -55
  50. package/dist-server/server/screen-frame-gate.js +9 -6
  51. package/dist-server/server/skill-library.js +5 -5
  52. package/dist-server/server/steer-queue.js +20 -1
  53. package/dist-server/server/store.js +29 -7
  54. package/dist-server/server/system-prompt.js +14 -1
  55. package/dist-server/server/team-backup.js +53 -4
  56. package/dist-server/server/tool-summary.js +39 -0
  57. package/dist-server/server/tts/speech-text.js +3 -1
  58. package/dist-server/server/turn-context.js +18 -0
  59. package/dist-server/server/workspace.js +321 -22
  60. package/dist-server/shared/approval-mode.js +1 -1
  61. package/dist-server/shared/team-backup.js +14 -2
  62. package/dist-server/vps-container-mcp.js +6 -2
  63. package/package.json +1 -1
  64. package/skills/create-verification-skill/SKILL.md +2 -3
  65. package/dist/assets/index-COp5lMnv.js +0 -279
  66. package/dist/assets/index-Jk7TIeyW.css +0 -1
@@ -449,6 +449,7 @@ export class RoutineManager {
449
449
  timeoutMinutes: loadTimeoutMinutes(routine.timeoutMinutes),
450
450
  attachments: loadAttachments(routine.attachments),
451
451
  sourceThreadId: persistedSourceThreadId.parse(routine.sourceThreadId),
452
+ resultsThreadId: persistedSourceThreadId.parse(routine.resultsThreadId),
452
453
  };
453
454
  if (loaded.timeoutMinutes === undefined)
454
455
  delete loaded.timeoutMinutes;
@@ -467,6 +468,7 @@ export class RoutineManager {
467
468
  timeoutMinutes: loadTimeoutMinutes(run.timeoutMinutes),
468
469
  attachments: loadAttachments(run.attachments),
469
470
  sourceThreadId: persistedSourceThreadId.parse(run.sourceThreadId),
471
+ resultsThreadId: persistedSourceThreadId.parse(run.resultsThreadId),
470
472
  };
471
473
  if (loaded.timeoutMinutes === undefined)
472
474
  delete loaded.timeoutMinutes;
@@ -619,11 +621,12 @@ export class RoutineManager {
619
621
  createdAt: at,
620
622
  updatedAt: at,
621
623
  };
624
+ const discardResults = this.applyResultsInput(routine, input.resultsThreadId);
622
625
  this.commitMutation(() => {
623
626
  this.routines.unshift(routine);
624
627
  if (request)
625
628
  this.rememberRoutineRequest(request, routine.id, at);
626
- });
629
+ }, discardResults);
627
630
  this.emitRoutine(routine);
628
631
  return cloneRoutine(routine);
629
632
  }
@@ -664,9 +667,14 @@ export class RoutineManager {
664
667
  if (clean.schedule.type === "interval" && clean.enabled && nextRunAt === null) {
665
668
  throw new Error("This interval has no future runs. Choose a later end date or turn it off.");
666
669
  }
670
+ const destination = { ...routine, ...clean };
671
+ if (destination.botId !== routine.botId)
672
+ delete destination.resultsThreadId;
673
+ const discardResults = this.applyResultsInput(destination, patch.resultsThreadId);
667
674
  const cancelledRuns = [];
668
675
  this.commitMutation(() => {
669
676
  Object.assign(routine, clean, {
677
+ resultsThreadId: destination.resultsThreadId,
670
678
  nextRunAt,
671
679
  // `updatedAt` doubles as the optimistic revision on durable routine
672
680
  // confirmation cards. Keep it monotonic even for two writes in one ms.
@@ -692,7 +700,7 @@ export class RoutineManager {
692
700
  }
693
701
  if (request)
694
702
  this.rememberRoutineRequest(request, routine.id, now);
695
- });
703
+ }, discardResults);
696
704
  for (const run of cancelledRuns)
697
705
  this.emitRun(run);
698
706
  this.emitRoutine(routine);
@@ -803,15 +811,19 @@ export class RoutineManager {
803
811
  if (!routine)
804
812
  return null;
805
813
  let run;
814
+ const allocations = [];
815
+ const previousDestination = routine.resultsThreadId;
806
816
  this.commitMutation(() => {
807
- run = this.newRun(routine, this.now(), true);
808
- // A chat-confirmed "run now" reports back to the conversation that
809
- // invoked this one run. It must not silently rebind future schedules.
817
+ run = this.newRun(routine, this.now(), true, allocations, request?.threadId ?? routine.sourceThreadId);
818
+ // Preserve the invoking chat as provenance/fallback for this run.
819
+ // An explicitly configured results destination continues to win.
810
820
  if (request)
811
821
  run.sourceThreadId = request.threadId;
812
822
  if (request)
813
823
  this.rememberRoutineRequest(request, run.id, this.now());
814
- });
824
+ }, () => this.discardResultsThreads(allocations));
825
+ if (routine.resultsThreadId !== previousDestination)
826
+ this.emitRoutine(routine);
815
827
  this.emitRun(run);
816
828
  queueMicrotask(() => void this.tick());
817
829
  return cloneRun(run);
@@ -978,55 +990,57 @@ export class RoutineManager {
978
990
  await this.options.interruptTurn?.(run.botId, threadId, run.runOn ?? "maus").catch(() => { });
979
991
  }
980
992
  }
981
- let changed = false;
982
- const missedRuns = [];
983
- for (const routine of this.routines) {
984
- if (!routine.enabled || routine.nextRunAt == null || routine.nextRunAt > now)
985
- continue;
986
- const pendingAt = routine.nextRunAt;
987
- const late = now - pendingAt;
988
- const scheduledFor = routine.schedule.type === "interval" && late <= CATCH_UP_MS
989
- ? latestIntervalOccurrence(routine.schedule, now) ?? pendingAt
990
- : pendingAt;
991
- // One slow interval run must not build an unbounded queue of stale
992
- // copies behind it. The series still advances on its original phase.
993
- const overlapping = routine.schedule.type === "interval" && this.runs.some((run) => run.routineId === routine.id && ["queued", "running", "waiting"].includes(run.status));
994
- if (!overlapping) {
995
- if (late > CATCH_UP_MS) {
996
- const missed = this.newRun(routine, scheduledFor, false);
997
- missed.status = "missed";
998
- missed.finishedAt = now;
999
- missed.error = "This computer was offline for more than 12 hours after the scheduled time";
1000
- this.emitRun(missed);
1001
- missedRuns.push(cloneRun(missed));
1002
- }
1003
- else {
1004
- const run = this.newRun(routine, scheduledFor, false);
1005
- this.emitRun(run);
993
+ const dueRoutines = this.routines.filter((routine) => routine.enabled && routine.nextRunAt != null && routine.nextRunAt <= now);
994
+ const scheduledRuns = [];
995
+ const allocations = [];
996
+ if (dueRoutines.length > 0) {
997
+ this.commitMutation(() => {
998
+ for (const routine of dueRoutines) {
999
+ const pendingAt = routine.nextRunAt;
1000
+ const late = now - pendingAt;
1001
+ const scheduledFor = routine.schedule.type === "interval" && late <= CATCH_UP_MS
1002
+ ? latestIntervalOccurrence(routine.schedule, now) ?? pendingAt
1003
+ : pendingAt;
1004
+ // One slow interval run must not build an unbounded queue of stale
1005
+ // copies behind it. The series still advances on its original phase.
1006
+ const overlapping = routine.schedule.type === "interval" && this.runs.some((run) => run.routineId === routine.id && ["queued", "running", "waiting"].includes(run.status));
1007
+ if (!overlapping) {
1008
+ const run = this.newRun(routine, scheduledFor, false, allocations);
1009
+ if (late > CATCH_UP_MS) {
1010
+ run.status = "missed";
1011
+ run.finishedAt = now;
1012
+ run.error = "This computer was offline for more than 12 hours after the scheduled time";
1013
+ }
1014
+ scheduledRuns.push(run);
1015
+ }
1016
+ routine.nextRunAt =
1017
+ routine.schedule.type === "once" ? null : nextOccurrence(routine.schedule, Math.max(now, scheduledFor));
1018
+ // `updatedAt` is the optimistic definition revision carried by
1019
+ // routine confirmation cards. Moving the scheduler cursor is runtime
1020
+ // progress, not a definition edit, so recurring ticks must not make a
1021
+ // still-accurate pending confirmation stale. A one-time routine does
1022
+ // mutate its definition by auto-disabling after its occurrence.
1023
+ if (routine.schedule.type === "once") {
1024
+ routine.enabled = false;
1025
+ routine.updatedAt = Math.max(now, routine.updatedAt + 1);
1026
+ }
1027
+ else if (routine.schedule.type === "interval" && routine.nextRunAt === null) {
1028
+ routine.enabled = false;
1029
+ routine.updatedAt = Math.max(now, routine.updatedAt + 1);
1030
+ }
1006
1031
  }
1007
- }
1008
- routine.nextRunAt =
1009
- routine.schedule.type === "once" ? null : nextOccurrence(routine.schedule, Math.max(now, scheduledFor));
1010
- // `updatedAt` is the optimistic definition revision carried by
1011
- // routine confirmation cards. Moving the scheduler cursor is runtime
1012
- // progress, not a definition edit, so recurring ticks must not make a
1013
- // still-accurate pending confirmation stale. A one-time routine does
1014
- // mutate its definition by auto-disabling after its occurrence.
1015
- if (routine.schedule.type === "once") {
1016
- routine.enabled = false;
1017
- routine.updatedAt = Math.max(now, routine.updatedAt + 1);
1018
- }
1019
- else if (routine.schedule.type === "interval" && routine.nextRunAt === null) {
1020
- routine.enabled = false;
1021
- routine.updatedAt = Math.max(now, routine.updatedAt + 1);
1022
- }
1032
+ }, () => this.discardResultsThreads(allocations));
1033
+ }
1034
+ // Reporting may persist transcript cards, so publish only after the
1035
+ // scheduler batch (including each destination) is durable.
1036
+ for (const routine of dueRoutines)
1023
1037
  this.emitRoutine(routine);
1024
- changed = true;
1038
+ for (const run of scheduledRuns)
1039
+ this.emitRun(run);
1040
+ for (const run of scheduledRuns) {
1041
+ if (run.status === "missed")
1042
+ this.options.onRunFailed?.(cloneRun(run));
1025
1043
  }
1026
- if (changed)
1027
- this.save();
1028
- for (const missed of missedRuns)
1029
- this.options.onRunFailed?.(missed);
1030
1044
  // Oldest queued requests have priority. New manual/webhook arrivals
1031
1045
  // must not continually overtake work that has already waited. Snapshot
1032
1046
  // the queue because each dispatch can asynchronously add/cancel work.
@@ -1262,7 +1276,16 @@ export class RoutineManager {
1262
1276
  return schedule.at;
1263
1277
  return nextOccurrence(schedule, now);
1264
1278
  }
1265
- newRun(routine, scheduledFor, manual) {
1279
+ newRun(routine, scheduledFor, manual, allocations, sourceThreadId = routine.sourceThreadId) {
1280
+ if (routine.target === "bot" && this.options.resolveResultsThread) {
1281
+ const destination = this.options.resolveResultsThread({ ...routine, sourceThreadId }, false);
1282
+ if (destination !== routine.resultsThreadId) {
1283
+ if (destination)
1284
+ allocations.push({ botId: routine.botId, threadId: destination });
1285
+ routine.resultsThreadId = destination;
1286
+ routine.updatedAt = Math.max(this.now(), routine.updatedAt + 1);
1287
+ }
1288
+ }
1266
1289
  const run = {
1267
1290
  id: randomUUID(),
1268
1291
  routineId: routine.id,
@@ -1279,12 +1302,44 @@ export class RoutineManager {
1279
1302
  status: "queued",
1280
1303
  manual,
1281
1304
  triggerSource: manual ? "manual" : "schedule",
1282
- sourceThreadId: routine.sourceThreadId,
1305
+ sourceThreadId,
1306
+ resultsThreadId: routine.resultsThreadId,
1283
1307
  createdAt: this.now(),
1284
1308
  };
1285
1309
  this.runs.push(run);
1286
1310
  return run;
1287
1311
  }
1312
+ discardResultsThreads(allocations) {
1313
+ for (const { botId, threadId } of allocations) {
1314
+ try {
1315
+ this.options.discardResultsThread?.(botId, threadId);
1316
+ }
1317
+ catch (error) {
1318
+ console.error("routine: could not discard uncommitted results thread", error);
1319
+ }
1320
+ }
1321
+ }
1322
+ applyResultsInput(routine, value) {
1323
+ if (routine.target !== "bot") {
1324
+ if (value != null)
1325
+ throw Object.assign(new Error("Results threads are only available for bot routines"), { status: 400 });
1326
+ delete routine.resultsThreadId;
1327
+ return;
1328
+ }
1329
+ if (value === undefined)
1330
+ return;
1331
+ if (value === null) {
1332
+ const destination = this.options.resolveResultsThread?.(routine, true);
1333
+ if (!destination)
1334
+ throw new Error("Could not create a results thread for this routine");
1335
+ routine.resultsThreadId = destination;
1336
+ return () => this.options.discardResultsThread?.(routine.botId, destination);
1337
+ }
1338
+ if (typeof value !== "string" || !value.trim() || !this.options.isResultsThread?.(routine.botId, value.trim())) {
1339
+ throw Object.assign(new Error("Choose a visible results thread belonging to this bot"), { status: 400 });
1340
+ }
1341
+ routine.resultsThreadId = value.trim();
1342
+ }
1288
1343
  emitRoutine(routine) {
1289
1344
  this.options.emit?.({ kind: "routine", routine: cloneRoutine(routine) });
1290
1345
  }
@@ -1331,7 +1386,7 @@ export class RoutineManager {
1331
1386
  * state if writing or renaming that file fails so a retry cannot mistake an
1332
1387
  * uncommitted action for a durable one.
1333
1388
  */
1334
- commitMutation(mutate) {
1389
+ commitMutation(mutate, rollback) {
1335
1390
  const before = {
1336
1391
  routines: this.routines.map(cloneRoutine),
1337
1392
  runs: this.runs.map(cloneRun),
@@ -1345,6 +1400,12 @@ export class RoutineManager {
1345
1400
  this.routines = before.routines;
1346
1401
  this.runs = before.runs;
1347
1402
  this.routineRequestReceipts = before.receipts;
1403
+ try {
1404
+ rollback?.();
1405
+ }
1406
+ catch (cleanupError) {
1407
+ console.error("routine: could not discard uncommitted results thread", cleanupError);
1408
+ }
1348
1409
  throw error;
1349
1410
  }
1350
1411
  }
@@ -73,13 +73,16 @@ const SCREEN_TOUCHING_TOOLS = new Set([
73
73
  "bring_to_front",
74
74
  "zoom",
75
75
  ]);
76
- /** The same tool reaches the poke site under three spellings: the Claude
77
- * driver's `mcp__computer__click`, Codex's bare `click`, and pi's
78
- * `computer_click` (server_tool, lowercased). A server prefix is stripped
76
+ // Keep legacy MCP namespaces accepted; desktop server__tool names follow
77
+ // MCP_NAME in mcp-registry.ts (lowercase letters, digits, underscores, hyphens).
78
+ const TOOL_NAMESPACE = /^(?:mcp__.+?|[a-z][a-z0-9_-]{0,31})__/;
79
+ /** The same tool reaches the poke site as `mcp__computer__click`,
80
+ * `computer__click`, bare `click`, or pi's `computer_click`.
81
+ * A single-underscore server prefix is stripped
79
82
  * at most once, so pi's `computer_computer_exec` lands on `computer_exec`
80
83
  * — still a shell — and never on a bare `exec`. */
81
84
  export function screenTouchingTool(toolName) {
82
- const bare = toolName.toLowerCase().replace(/^mcp__.+?__/, "");
85
+ const bare = toolName.toLowerCase().replace(TOOL_NAMESPACE, "");
83
86
  return SCREEN_TOUCHING_TOOLS.has(bare) || SCREEN_TOUCHING_TOOLS.has(bare.replace(/^(?:computer|browser)_/, ""));
84
87
  }
85
88
  /** sha256 over the base64 frame — the same fingerprint the model-side
@@ -104,9 +107,9 @@ export function screenSurfaceForTool(toolName) {
104
107
  // in agent-browser. Keep the server identity before stripping prefixes.
105
108
  if (name.startsWith("mcp__computer__") || name.startsWith("computer_"))
106
109
  return "computer";
107
- if (name.startsWith("mcp__browser__"))
110
+ if (name.startsWith("mcp__browser__") || name.startsWith("browser__"))
108
111
  return "browser";
109
- const bare = name.replace(/^mcp__.+?__/, "");
112
+ const bare = name.replace(TOOL_NAMESPACE, "");
110
113
  // Codex reports bare names. These two belong to computer-proxy; the
111
114
  // standalone browser now uses the unambiguous agent_browser_* names.
112
115
  if (bare === "browser_click" || bare === "browser_fill")
@@ -64,9 +64,9 @@ export function loadBundledSkills(root = process.env.OMB_SKILLS_DIR || join(proc
64
64
  }
65
65
  return skills;
66
66
  }
67
- /** User-authored skills are hot-loaded on each turn so a just-recorded skill
68
- * works without restarting the desktop app. One hand-edited broken folder is
69
- * isolated instead of taking down every bot turn. */
67
+ /** User skills are hot-loaded on each turn so a skill that was just enabled
68
+ * or hand-authored works without restarting the desktop app. One broken
69
+ * folder is isolated instead of taking down every bot turn. */
70
70
  export function loadUserSkills(root) {
71
71
  if (!existsSync(root))
72
72
  return [];
@@ -85,8 +85,8 @@ export function loadUserSkills(root) {
85
85
  skills.push(skill);
86
86
  }
87
87
  catch {
88
- // The recorder always writes atomically validated folders, but people
89
- // are free to edit them later. A malformed edit disables only itself.
88
+ // Skills the app writes are validated first, but people are free to
89
+ // hand-edit the folders later. A malformed edit disables only itself.
90
90
  }
91
91
  }
92
92
  return skills;
@@ -57,11 +57,27 @@ export function queueSteeredMessage(botId, threadId, text, options = {}) {
57
57
  replyToId: options.replyToId,
58
58
  sendId: options.sendId,
59
59
  reason: options.reason,
60
+ unattended: options.unattended,
60
61
  });
61
62
  queues.set(threadId, entry);
62
63
  changed();
63
64
  return { id };
64
65
  }
66
+ /** Where a thread stands among this bot's threads waiting for a free slot:
67
+ * 1 for the next to start. The drain visits queues in insertion order, so
68
+ * insertion order is the line. Null when nothing of this bot's is waiting
69
+ * on that thread. */
70
+ export function queuedThreadPosition(botId, threadId) {
71
+ let position = 0;
72
+ for (const [candidate, entry] of queues) {
73
+ if (entry.botId !== botId || !entry.items.some((item) => item.reason === "capacity"))
74
+ continue;
75
+ position += 1;
76
+ if (candidate === threadId)
77
+ return position;
78
+ }
79
+ return null;
80
+ }
65
81
  /** Drain every queue whose task is idle: append the held lines (leaf is now
66
82
  * the finished turn's last item), then one run per thread whose prompt is
67
83
  * the texts separated by a blank line. `userMessage` is the last appended line
@@ -108,7 +124,10 @@ export function drainSteeredMessages(store, run, isBlocked) {
108
124
  // message into one HTML block, which makes that attachment stop being a
109
125
  // native image when the combined follow-up is dispatched.
110
126
  const prompt = entry.items.map((item) => item.prompt).join("\n\n");
111
- void run(entry.botId, threadId, prompt, last, appended.map((message) => message.id));
127
+ void run(entry.botId, threadId, prompt, last, appended.map((message) => message.id),
128
+ // one unattended line makes the whole drained turn unattended: a
129
+ // person's words in the same queue cannot re-attend a bot's own
130
+ entry.items.some((item) => item.unattended === true));
112
131
  }
113
132
  }
114
133
  /** Find the receipt for a retry whose message is still waiting to drain. */
@@ -26,10 +26,12 @@ export function isProjectEmoji(value) {
26
26
  const TASK_PATCH_FIELDS = [
27
27
  "title", "projectId", "modelSelection", "approvalMode", "autoApprove", "alwaysAllow",
28
28
  "unread", "rewound", "pinnedMessageId", "resumeCursors", "lastInstanceId", "cwd",
29
+ "routineRunId",
29
30
  ];
30
31
  /** Everything the BOT authored is scrubbed of content-shaped secrets before
31
32
  * it is stored: its reply text, a tool title (an ACP engine's title can be
32
- * the whole command line), a permission card's summary. What the user typed
33
+ * the whole command line) and the command beside it, a permission card's
34
+ * summary. What the user typed
33
35
  * is theirs and stays as typed. Stored, not just displayed: the transcript
34
36
  * is replayed into every rebuild, and a leaked key would otherwise be
35
37
  * permanent. */
@@ -39,8 +41,11 @@ function redactBotAuthored(message) {
39
41
  const out = { ...message };
40
42
  if (typeof out.text === "string")
41
43
  out.text = redactSecretsInText(out.text);
42
- if (out.tool?.name)
44
+ if (out.tool?.name) {
43
45
  out.tool = { ...out.tool, name: redactSecretsInText(out.tool.name) };
46
+ if (out.tool.summary)
47
+ out.tool.summary = redactSecretsInText(out.tool.summary);
48
+ }
44
49
  if (out.routineRun) {
45
50
  const routineRun = { ...out.routineRun };
46
51
  routineRun.routineName = redactSecretsInText(routineRun.routineName);
@@ -541,7 +546,7 @@ export class Store {
541
546
  return () => this.listeners.delete(listener);
542
547
  }
543
548
  emit(change) {
544
- for (const listener of [...this.listeners]) {
549
+ for (const listener of Array.from(this.listeners)) {
545
550
  try {
546
551
  listener(change);
547
552
  }
@@ -1444,7 +1449,7 @@ export class Store {
1444
1449
  }
1445
1450
  /** A fresh context on the same bot: new thread, new session, same
1446
1451
  * persona/tools/computer. Becomes the active task. */
1447
- createTask(botId, title, activate = true, projectId) {
1452
+ createTask(botId, title, activate = true, projectId, openedBy) {
1448
1453
  const bot = this.bot(botId);
1449
1454
  if (!bot)
1450
1455
  return null;
@@ -1455,6 +1460,7 @@ export class Store {
1455
1460
  title: title?.trim().slice(0, 80) || UNTITLED_THREAD,
1456
1461
  createdAt: Date.now(),
1457
1462
  ...(projectId ? { projectId } : {}),
1463
+ ...(openedBy ? { openedBy: structuredClone(openedBy) } : {}),
1458
1464
  resumeCursors: {},
1459
1465
  modelSelection: structuredClone(bot.modelSelection),
1460
1466
  approvalMode: approvalModeFor(bot),
@@ -1472,6 +1478,20 @@ export class Store {
1472
1478
  this.emit({ type: "bot", botId });
1473
1479
  return task;
1474
1480
  }
1481
+ /** Attach (or complete) the opener record after the thread exists — the
1482
+ * handoff id is only known once the thread it targets has an id, so a
1483
+ * peer-opened thread is created first and stamped second. Never reachable
1484
+ * from the HTTP task PATCH: openedBy is not a TASK_PATCH_FIELD. */
1485
+ setTaskOpenedBy(botId, threadId, openedBy) {
1486
+ const bot = this.bot(botId);
1487
+ const task = this.taskByThread(botId, threadId);
1488
+ if (!bot || !task)
1489
+ return null;
1490
+ task.openedBy = structuredClone(openedBy);
1491
+ this.saveBots();
1492
+ this.emit({ type: "bot", botId });
1493
+ return task;
1494
+ }
1475
1495
  switchTask(botId, threadId) {
1476
1496
  const bot = this.bot(botId);
1477
1497
  const task = bot?.tasks?.find((t) => t.threadId === threadId);
@@ -1503,10 +1523,12 @@ export class Store {
1503
1523
  if (!bot.tasks.some((t) => t.threadId === threadId))
1504
1524
  return null;
1505
1525
  bot.tasks = bot.tasks.filter((t) => t.threadId !== threadId);
1506
- this.deleteThreadRecord(threadId);
1507
- if (bot.threadId === threadId) {
1508
- this.mirrorActiveTask(bot, bot.tasks[0]);
1526
+ const visible = bot.tasks.find((task) => !task.routineRunId)
1527
+ ?? this.createTask(botId, undefined, bot.threadId === threadId);
1528
+ if (bot.threadId === threadId || this.taskByThread(botId, bot.threadId)?.routineRunId) {
1529
+ this.mirrorActiveTask(bot, visible);
1509
1530
  }
1531
+ this.deleteThreadRecord(threadId);
1510
1532
  bot.unread = bot.tasks.some((task) => task.unread);
1511
1533
  this.refreshBotActivity(bot);
1512
1534
  this.saveBots();
@@ -6,6 +6,17 @@
6
6
  // The sentences that both the direct-turn and room-turn paths use live
7
7
  // here too, so neither path can drift from the other or from the preview.
8
8
  import { soulSystemPrompt } from "./bot-folder.js";
9
+ /** Sections whose text legitimately differs between two turns of one live
10
+ * conversation: memory, because a bot writes to MEMORY.md mid-conversation,
11
+ * and mentions, which describe the message being sent right now.
12
+ *
13
+ * They are reported apart from the rest so a driver that keeps one CLI
14
+ * process per thread can key that process on the stable half. Before this
15
+ * split, saving a memory changed the system prompt, which changed the spawn
16
+ * contract, which relaunched the CLI — and the provider then re-uploaded the
17
+ * entire conversation at the cache-write rate. Mentions did the same on any
18
+ * turn that tagged a bot. */
19
+ const VOLATILE_SECTIONS = new Set(["memory", "mentions"]);
9
20
  export function buildSystemPrompt(persona, soul, parts) {
10
21
  const ordered = [
11
22
  { id: "persona", label: "Identity", text: persona },
@@ -15,7 +26,8 @@ export function buildSystemPrompt(persona, soul, parts) {
15
26
  const sections = ordered
16
27
  .filter((part) => part.text.length > 0)
17
28
  .map((part) => ({ ...part, bytes: Buffer.byteLength(part.text, "utf8") }));
18
- return { text: sections.map((section) => section.text).join(""), sections };
29
+ const halves = (volatile) => sections.filter((section) => VOLATILE_SECTIONS.has(section.id) === volatile).map((section) => section.text).join("");
30
+ return { text: sections.map((section) => section.text).join(""), sections, stable: halves(false), volatile: halves(true) };
19
31
  }
20
32
  const PROTECTED_INPUT_GUARD = " At a sign-in, password, MFA, CAPTCHA, or other protected-input step, stop and ask the user to complete it on the visible computer. Never type their password or ask them to paste a password or one-time code into chat.";
21
33
  const COMPUTER_PARAGRAPH = {
@@ -36,6 +48,7 @@ export function computerPrompt(kind) {
36
48
  }
37
49
  export const COMPOSIO_PROMPT = " The user's connected apps (Gmail, Calendar, Slack, Notion, and the rest) are reachable through the composio tools — find the right one with COMPOSIO_SEARCH_TOOLS, read its arguments with COMPOSIO_GET_TOOL_SCHEMAS, then run it with COMPOSIO_MULTI_EXECUTE_TOOL. Reach for them before telling the user you have no access to a service.";
38
50
  export const CREDENTIAL_PROMPT = " If a supported API key is missing, use request_credential to create a secure credential request. A freshly QR-paired mobile app or the desktop app can show the secure entry card. Never claim it opened unless the request succeeded, and never ask the user to paste credentials into chat.";
51
+ export const THREADS_PROMPT = " A thread is one conversation with its own history and its own run; a bot can have several running at once, and the person sees them as rows under that bot. Use start_thread to open one on yourself for separate work, or on a teammate to hand them a job that should run on its own. Use list_threads to see how the ones you opened are going. When you mention a thread to the person, write its title as #Title so it links. Do not use a ticket comment, a note, or a room post as a stand-in for a thread.";
39
52
  export const ROUTINE_PROMPT = " If the user explicitly asks to list or review, schedule, run, or change routines, use list_routines and propose_routine or propose_routine_action. A proposal is not applied until the user confirms its in-app card, so never claim the action completed before that confirmation.";
40
53
  export const ROUTINE_EXECUTION_PROMPT = " Execute this routine now: use available peer tools for required handoffs rather than merely announcing that you will wait; after an accepted delegation, end this turn for automatic resumption, and report a concrete blocker if no handoff is possible.";
41
54
  export const LEARN_PROMPT = " If the user sends /learn or asks you to save a reusable procedure from this work, use skills_list and skill_manage. Create new skills; update an existing learned skill only when the user explicitly asks to revise that exact name. Include source provenance and wait for the review card decision.";
@@ -2,6 +2,38 @@ import { newId } from "./contracts.js";
2
2
  import { botMascotBody } from "../shared/mascot-bodies.js";
3
3
  import { takeImportName } from "../shared/import-name.js";
4
4
  import { MAX_TEAM_BACKUP_BYTES, parseTeamBackup } from "../shared/team-backup.js";
5
+ import { redactSecretsInText } from "./redact.js";
6
+ import { listMemoryLogs, listMemoryTopics, readMemoryFile, readMemoryLog, readMemoryTopic, writeMemoryFile, writeMemoryLog, writeMemoryTopic, } from "./workspace.js";
7
+ /** A bot's memory as it travels: MEMORY.md, every topic file, every daily
8
+ * log — scrubbed on the way out, because a file the bot's own file tools
9
+ * wrote never passed the server's scrub. Absent when the bot has none, so
10
+ * a backup of a bot that never remembered anything is unchanged. */
11
+ function memoryFor(botId) {
12
+ const file = redactSecretsInText(readMemoryFile(botId).text);
13
+ const topics = listMemoryTopics(botId).flatMap((topic) => {
14
+ const text = readMemoryTopic(botId, topic.name);
15
+ return text === null ? [] : [{ name: topic.name, text: redactSecretsInText(text) }];
16
+ });
17
+ const logs = listMemoryLogs(botId).flatMap((name) => {
18
+ const text = readMemoryLog(botId, name);
19
+ return text === null ? [] : [{ name, text: redactSecretsInText(text) }];
20
+ });
21
+ if (!file && !topics.length && !logs.length)
22
+ return undefined;
23
+ return { file, topics, logs };
24
+ }
25
+ /** Restore a bot's memory into its fresh workspace: the same writers the
26
+ * tool and the editor use, so modes (0700 folders, 0600 files), the scrub
27
+ * and the search index all come for free. Runs before any transcript is
28
+ * restored, inside the import's rollback — deleteBot removes the workspace. */
29
+ function restoreMemory(botId, memory) {
30
+ if (memory.file)
31
+ writeMemoryFile(botId, memory.file);
32
+ for (const topic of memory.topics)
33
+ writeMemoryTopic(botId, topic.name, topic.text);
34
+ for (const log of memory.logs)
35
+ writeMemoryLog(botId, log.name, log.text);
36
+ }
5
37
  /** Preserve readable history without importing executable cards, live queue
6
38
  * entries, approval requests, local paths or provider session handles. */
7
39
  function messageText(message) {
@@ -29,6 +61,11 @@ export function createTeamBackup(store, routines, name) {
29
61
  const tasks = record.tasks?.length ? record.tasks : [{ threadId: record.threadId, title: "Conversation", createdAt: record.createdAt }];
30
62
  return tasks.map((task) => ({
31
63
  key: task.threadId, title: task.title, createdAt: task.createdAt,
64
+ // Who opened the thread travels; the handoff id does not — the
65
+ // delegation ledger is process-local and never part of a backup.
66
+ openedBy: "openedBy" in task && task.openedBy
67
+ ? { botId: task.openedBy.botId, name: task.openedBy.name, at: task.openedBy.at }
68
+ : undefined,
32
69
  activeLeafId: store.activeLeaf(task.threadId),
33
70
  messages: store.messagesFor(task.threadId).map((message) => ({
34
71
  id: message.id, role: message.role, text: messageText(message), at: message.at,
@@ -64,6 +101,7 @@ export function createTeamBackup(store, routines, name) {
64
101
  section: bot.section, color: bot.color,
65
102
  mascotExpression: bot.mascotExpression ?? undefined, mascotBody: bot.mascotBody ?? undefined,
66
103
  chiefOfStaff: Boolean(bot.chiefOfStaff), hidden: Boolean(bot.hidden), playbooks: bot.playbooks ?? [],
104
+ memory: memoryFor(bot.id),
67
105
  activeTask: bot.threadId, tasks: history(bot),
68
106
  })),
69
107
  groups,
@@ -128,13 +166,24 @@ export function importTeamBackup(store, routines, input, selection) {
128
166
  botIds.set(source.key, bot.id);
129
167
  store.patchBot(bot.id, { composio: false, computer: "off", browser: false, approvalMode: "ask", autoApprove: false,
130
168
  hidden: source.hidden, chiefOfStaff: source.chiefOfStaff, playbooks: source.playbooks });
169
+ if (source.memory)
170
+ restoreMemory(bot.id, source.memory);
131
171
  }
132
172
  for (const source of backup.bots) {
133
173
  const bot = store.bot(botIds.get(source.key));
134
- const tasks = source.tasks.map((task, i) => ({
135
- threadId: i === 0 ? bot.threadId : newId(), title: task.title, createdAt: task.createdAt, resumeCursors: {},
136
- modelSelection: structuredClone(selection), activity: "idle", busy: false, unread: false,
137
- }));
174
+ const tasks = source.tasks.map((task, i) => {
175
+ const record = {
176
+ threadId: i === 0 ? bot.threadId : newId(), title: task.title, createdAt: task.createdAt, resumeCursors: {},
177
+ modelSelection: structuredClone(selection), activity: "idle", busy: false, unread: false,
178
+ };
179
+ // Same rule as a message's `from`: the opener is remapped to its
180
+ // imported twin, and an opener outside this backup leaves no record
181
+ // rather than a bot id that resolves to a stranger.
182
+ const opener = task.openedBy && botIds.get(task.openedBy.botId);
183
+ if (task.openedBy && opener)
184
+ record.openedBy = { botId: opener, name: task.openedBy.name, at: task.openedBy.at };
185
+ return record;
186
+ });
138
187
  // Own the task IDs before writing their transcripts, so rollback also
139
188
  // removes partially imported history if persistence fails midway.
140
189
  store.patchBot(bot.id, { tasks, threadId: tasks[source.tasks.findIndex((task) => task.key === source.activeTask)].threadId });
@@ -0,0 +1,39 @@
1
+ // What a tool call is about to do, in words a chip or a permission card can
2
+ // show. Redacted before it is cut: a command line is where credentials get
3
+ // pasted, and a key sliced in half would slip past the shapes redaction knows.
4
+ import { redactSecretsInText } from "./redact.js";
5
+ const QUESTION_LIMIT = 300;
6
+ const LIMIT = 200;
7
+ function fieldsOf(input) {
8
+ if (input === null || typeof input !== "object" || Array.isArray(input))
9
+ return undefined;
10
+ return input;
11
+ }
12
+ const cut = (text, limit) => redactSecretsInText(text).trim().slice(0, limit);
13
+ /** The shell command a tool call runs, on one redacted line of at most 200
14
+ * characters — what rides beside the tool name on the chip and what the
15
+ * Verify card reads as a step. Only a command: a Read's path or a fetch's
16
+ * URL is not something the bot ran, so those calls carry no summary. */
17
+ export function commandSummary(input) {
18
+ const command = fieldsOf(input)?.command;
19
+ if (typeof command !== "string")
20
+ return undefined;
21
+ return cut(command.replace(/\s*[\r\n]+\s*/g, " "), LIMIT);
22
+ }
23
+ /** The permission card's subtitle: the question asked, else the command as
24
+ * the bot wrote it (newlines kept — a multi-line command reads on the card
25
+ * the way it will run), else the URL, else the arguments as JSON. Undefined
26
+ * when there is nothing to say (no input, or an empty object). */
27
+ export function askInputSummary(input) {
28
+ const fields = fieldsOf(input);
29
+ if (!fields)
30
+ return undefined;
31
+ if (typeof fields.question === "string")
32
+ return cut(fields.question, QUESTION_LIMIT);
33
+ if (typeof fields.command === "string")
34
+ return cut(fields.command, LIMIT);
35
+ if (typeof fields.url === "string")
36
+ return cut(fields.url, LIMIT);
37
+ const text = JSON.stringify(fields);
38
+ return text === "{}" ? undefined : cut(text, LIMIT);
39
+ }
@@ -88,7 +88,9 @@ export function speakable(input) {
88
88
  text = shortenPaths(text);
89
89
  // emoji and the pictographic ranges: a voice either ignores them or,
90
90
  // worse, announces them by name
91
- text = text.replace(/[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{FE00}-\u{FE0F}\u{2190}-\u{21FF}\u{2B00}-\u{2BFF}]/gu, "");
91
+ text = text.replace(
92
+ // oxlint-disable-next-line no-misleading-character-class -- variation selectors are stripped on their own, not as part of a grapheme
93
+ /[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{FE00}-\u{FE0F}\u{2190}-\u{21FF}\u{2B00}-\u{2BFF}]/gu, "");
92
94
  // a blank line is a paragraph break — make it an audible one
93
95
  text = text.replace(/\n{2,}/g, ". ");
94
96
  text = text.replace(/\n/g, ". ");