openmausbot 0.1.68 → 0.1.70

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 (41) hide show
  1. package/dist/assets/index-B2F__IRS.css +1 -0
  2. package/dist/assets/index-CLj3t5Ba.js +280 -0
  3. package/dist/assets/{index-HqooKFTS.js → index-D1RGhLUK.js} +1 -1
  4. package/dist/index.html +2 -2
  5. package/dist-server/container-mcp.js +40 -2
  6. package/dist-server/drivers/agents-proxy.js +188 -5
  7. package/dist-server/index.js +2431 -984
  8. package/dist-server/local-computer-proxy.js +2 -0
  9. package/dist-server/openmausbot.js +923 -334
  10. package/dist-server/pair-cli.js +923 -334
  11. package/dist-server/prepare-cloudflared.js +20 -3
  12. package/dist-server/server/avatar-image.js +86 -34
  13. package/dist-server/server/bot-package.js +60 -14
  14. package/dist-server/server/config.js +23 -2
  15. package/dist-server/server/delegations.js +132 -74
  16. package/dist-server/server/drivers/acp/qwen.js +142 -5
  17. package/dist-server/server/drivers/agents-proxy.js +224 -4
  18. package/dist-server/server/drivers/claude-login-auth.js +102 -9
  19. package/dist-server/server/drivers/claude.js +11 -5
  20. package/dist-server/server/drivers/codex-device-auth.js +102 -3
  21. package/dist-server/server/drivers/codex-identity.js +115 -0
  22. package/dist-server/server/drivers/codex.js +6 -0
  23. package/dist-server/server/engine-install.js +137 -0
  24. package/dist-server/server/env-path.js +12 -0
  25. package/dist-server/server/harness/registry.js +33 -6
  26. package/dist-server/server/index.js +365 -26
  27. package/dist-server/server/package-export.js +7 -0
  28. package/dist-server/server/peer-provenance.js +6 -2
  29. package/dist-server/server/provider-auth-sessions.js +24 -0
  30. package/dist-server/server/routine-requests.js +270 -25
  31. package/dist-server/server/routines.js +297 -69
  32. package/dist-server/server/steer-queue.js +48 -1
  33. package/dist-server/server/store.js +22 -4
  34. package/dist-server/server/system-prompt.js +1 -0
  35. package/dist-server/server/team-backup.js +18 -4
  36. package/dist-server/shared/image-generation.js +28 -0
  37. package/dist-server/shared/team-backup.js +1 -0
  38. package/dist-server/vps-container-mcp.js +40 -2
  39. package/package.json +1 -1
  40. package/dist/assets/index-CbaejjQA.js +0 -278
  41. package/dist/assets/index-aJ5EWZOQ.css +0 -1
@@ -21,7 +21,7 @@ import { appendDecision, readDecisions } from "./decision-log.js";
21
21
  import { validateBotCwd } from "./bot-cwd.js";
22
22
  import { ATTACHMENTS_DIR, attachmentExists, cleanupStaleAttachmentPartials, deleteAttachment, extensionForMime, FILE_MAX_BYTES, IMAGE_MAX_BYTES, readAttachment, saveFile, saveImage, saveImageUpload, validateAttachmentUploadId, } from "./attachments.js";
23
23
  import { messageFileDisposition, messageFileDownloadName, messageAttachmentName, messageFileRoots, messageImageTargetAt, messageReferencesFile, openMessageFile, } from "./message-file.js";
24
- import { avatarGenerationRequestSchema, avatarGenerationStateMatches, generateAvatarImage, snapshotAvatarGenerationState, } from "./avatar-image.js";
24
+ import { avatarGenerationRequestSchema, avatarGenerationStateMatches, generateAvatarImage, avatarImageStatus, snapshotAvatarGenerationState, } from "./avatar-image.js";
25
25
  import { fitsOnOneLine, parseBotProfilePatch } from "./bot-profile.js";
26
26
  import { groupTurnCwd } from "./room-cwd.js";
27
27
  import { RoomTurnDeadline, RoomTurnStallRegistry, roomTurnTimeoutMessage } from "./room-turn-timeout.js";
@@ -37,6 +37,7 @@ import { ensureDirs, instanceConfigs, loadConfig, localVmMaxInstances, localVmMo
37
37
  import { ComputerControl } from "./computer-control.js";
38
38
  import { MAX_REMOTE_COMMAND_LENGTH } from "./remote-computer.js";
39
39
  import { augmentedPath, findCliCandidates, resetPathCache } from "./env-path.js";
40
+ import { registerEnginesBinDir } from "./engine-install.js";
40
41
  import { describeSpawnFailure, execCli } from "./procs.js";
41
42
  import { blockedTarget, buildNotification } from "./notify.js";
42
43
  import { isEffortLevel, newId, } from "./contracts.js";
@@ -54,7 +55,7 @@ import { claimRecallCrossings, recallCrossingLabel } from "./recall-disclosure.j
54
55
  const SESSION_READ_MAX_CHARS = 8_000;
55
56
  import { promptWithReply, transcriptText } from "./replies.js";
56
57
  import { _loadPending, buildDelegationFailurePrompt, buildDelegationRevivalPrompt, DelegationWakeBudget, discardDelegations, drainDelegations, findDelegationReceipt, pendingDelegationInfo, pendingDelegationSnapshot, pendingThreads, queueDelegation, recordDelegationReceipt, releaseDelegationsWaitingOn, summarizeDelegatedActivity } from "./delegations.js";
57
- import { cancelSteeredMessage, drainSteeredMessages, queuedSteeredMessage, queueSteeredMessage, } from "./steer-queue.js";
58
+ import { cancelSteeredMessage, drainSteeredMessages, onSteeredQueueChange, queuedSteerSnapshot, queuedSteeredMessage, queuedThreadPosition, queueSteeredMessage, } from "./steer-queue.js";
58
59
  import { cancelChannelMessage, drainChannelMessages, queuedChannelMessage, queueChannelMessage, } from "./channel-queue.js";
59
60
  import { acceptedSendMatch, parseSendId, sendFingerprint, SendSequencer, } from "./send-idempotency.js";
60
61
  import { EventBus } from "./harness/bus.js";
@@ -78,7 +79,7 @@ import { fetchSkillFromSource } from "./skill-fetch.js";
78
79
  import { expandLearnTurnText, learnSource } from "./skill-learn.js";
79
80
  import { expandSetupTurnText, setupModeActive, setupSystemPrompt } from "./setup-mode.js";
80
81
  import { checkSoulDrift, readSoulDrift, soulFile, writeSoulMirror } from "./bot-folder.js";
81
- import { buildSystemPrompt, computerPrompt, mentionPrompt, COMPOSIO_PROMPT, CREDENTIAL_PROMPT, LEARN_PROMPT, PROFILE_PROMPT, ROUTINE_PROMPT, ROUTINE_EXECUTION_PROMPT, WEBHOOK_PROMPT, } from "./system-prompt.js";
82
+ import { buildSystemPrompt, computerPrompt, mentionPrompt, COMPOSIO_PROMPT, CREDENTIAL_PROMPT, THREADS_PROMPT, LEARN_PROMPT, PROFILE_PROMPT, ROUTINE_PROMPT, ROUTINE_EXECUTION_PROMPT, WEBHOOK_PROMPT, } from "./system-prompt.js";
82
83
  import { readCuaConnection, gatedLocalComputer } from "./local-computer.js";
83
84
  import { discoverExistingPerBotLocalVms, localVmInventoryEntry, shouldArmLocalVmIdle, } from "./local-vm-inventory.js";
84
85
  import { LocalVmIdleTimer } from "./local-vm-idle.js";
@@ -208,6 +209,9 @@ function customDomainStatus() {
208
209
  };
209
210
  }
210
211
  const registry = new ProviderRegistry(BUILT_IN_DRIVERS);
212
+ // Engines installed from Settings live under the data directory and win over
213
+ // any other copy on PATH.
214
+ registerEnginesBinDir();
211
215
  const providerAuthSessions = new ProviderAuthSessions();
212
216
  await registry.load(instanceConfigs(cfg));
213
217
  const bundledSkills = loadBundledSkills();
@@ -426,6 +430,7 @@ function agentsIntegration(botId, threadId, depth, skillAuthoring, generation) {
426
430
  kind: "agents",
427
431
  skillAuthoring,
428
432
  createdBots: 0,
433
+ openedThreads: 0,
429
434
  });
430
435
  return {
431
436
  command: process.execPath,
@@ -672,7 +677,7 @@ async function browserIntegration(botId, profile, turn) {
672
677
  if (!turn)
673
678
  return { profile: partitionId, session, spec, integration: spec };
674
679
  const token = mintInternalCapability({ botId, ...turn, browserSession: session,
675
- kind: "browser", depth: 0, skillAuthoring: false, createdBots: 0 });
680
+ kind: "browser", depth: 0, skillAuthoring: false, createdBots: 0, openedThreads: 0 });
676
681
  return { profile: partitionId, session, spec, integration: {
677
682
  command: process.execPath, args: [SPAWNED_PROXIES.browser], env: {
678
683
  ...AGENTS_NODE_FLAG, OMB_BROWSER_TOKEN: token, OMB_HARNESS_URL: `http://127.0.0.1:${PORT}`,
@@ -708,6 +713,7 @@ function connectedAppsIntegration(botId, threadId, generation) {
708
713
  kind: "connectors",
709
714
  skillAuthoring: false,
710
715
  createdBots: 0,
716
+ openedThreads: 0,
711
717
  });
712
718
  return composio.mcpIntegration(cfg, {
713
719
  harnessUrl: `http://127.0.0.1:${PORT}`,
@@ -760,6 +766,7 @@ function controlIntegration(botId, threadId, generation, localVmTarget) {
760
766
  ...(localVmTarget ? { localVmTarget } : {}),
761
767
  skillAuthoring: false,
762
768
  createdBots: 0,
769
+ openedThreads: 0,
763
770
  }),
764
771
  };
765
772
  }
@@ -1368,6 +1375,7 @@ const publicBot = (bot) => ({
1368
1375
  activeLeafId: store.activeLeaf(bot.threadId),
1369
1376
  tasks: store.tasks(bot.id).map(wireTask),
1370
1377
  });
1378
+ const publicBotQueuedMessages = () => queuedSteerSnapshot((botId, threadId) => Boolean(store.taskByThread(botId, threadId)));
1371
1379
  // busyBotId names only the speaker that currently owns the provider process.
1372
1380
  // A room turn is wider: it also includes async setup and every responder still
1373
1381
  // queued behind that speaker. Keep that operation visible for its whole
@@ -1750,6 +1758,14 @@ store.onChange((change) => {
1750
1758
  break;
1751
1759
  case "thread.deleted":
1752
1760
  routines?.forgetRoutineRequestReceiptsForThread(change.threadId);
1761
+ // A deleted destination must not strand an approval in an internal
1762
+ // task. Keep each run's snapshot and expose its execution as fallback.
1763
+ for (const run of routines?.listRuns() ?? []) {
1764
+ if (run.resultsThreadId === change.threadId || run.sourceThreadId === change.threadId) {
1765
+ syncRoutineRunToSource(run);
1766
+ }
1767
+ }
1768
+ broadcast({ kind: "bot.queued", queues: publicBotQueuedMessages() });
1753
1769
  break;
1754
1770
  case "bot": {
1755
1771
  const bot = store.bot(change.botId);
@@ -1893,6 +1909,7 @@ function broadcast(payload) {
1893
1909
  }
1894
1910
  }
1895
1911
  }
1912
+ onSteeredQueueChange(() => broadcast({ kind: "bot.queued", queues: publicBotQueuedMessages() }));
1896
1913
  // ── server-side event folding (upstream's ingestion worker, miniature) ──
1897
1914
  // The canonical stream is the source of truth; the persisted transcript
1898
1915
  // and every client view are projections of it.
@@ -2999,13 +3016,12 @@ bus.subscribe((event) => {
2999
3016
  costUsd: event.cost ?? null,
3000
3017
  });
3001
3018
  const routineReportThread = routineRun ? routineSourceThread(routineRun) : null;
3002
- const routineReportGroup = routineReportThread ? store.groupByThread(routineReportThread) : undefined;
3003
- // Group-origin routines belong to that channel's unread state. Their
3004
- // hidden execution task should not light up the bot's 1:1 sidebar too.
3019
+ // A routine's result belongs to its reporting thread's unread state.
3020
+ // Its internal execution should not light up the sidebar as well.
3005
3021
  // Neither should a peer's hop: the exchange is already recorded in the
3006
3022
  // pair channel and chipped into both threads, which is the whole of
3007
3023
  // what the person needs to be able to find it.
3008
- if (!routineReportGroup && !internal)
3024
+ if (!routineReportThread && !internal)
3009
3025
  store.patchTask(bot.id, event.threadId, { unread: true });
3010
3026
  // A failed peer turn stays a chip too. The bot that delegated is woken
3011
3027
  // with the failure and answers the person in its own thread — buzzing
@@ -3354,13 +3370,27 @@ bus.subscribe((event) => {
3354
3370
  /** How a drained delegation becomes a real turn on the target. Shared by
3355
3371
  * the settle-time drain and the boot-time drain of what a previous process
3356
3372
  * left queued. */
3357
- const runDelegatedTurn = (toBotId, text, commsDepth, sourceThreadId, channel, taskId, sourceBotId) => {
3373
+ const runDelegatedTurn = (toBotId, rawText, commsDepth, sourceThreadId, channel, taskId, sourceBotId, openedThreadId) => {
3358
3374
  // startTurn REJECTS on an ordinary condition — busy target, deleted bot,
3359
3375
  // unavailable provider. Unhandled, that rejection is fatal to the
3360
3376
  // harness (Node's default), which in the packaged app kills the server
3361
3377
  // child. Every delegation failure has to land as a chip instead.
3362
- const targetThreadId = store.bot(toBotId)?.threadId;
3378
+ // A fresh-thread handoff runs in the thread the opener created — the
3379
+ // drain already dropped it if that thread is gone — never in whatever
3380
+ // the person is looking at.
3381
+ const targetThreadId = openedThreadId ?? store.bot(toBotId)?.threadId;
3363
3382
  const target = store.bot(toBotId);
3383
+ const opener = store.bot(sourceBotId);
3384
+ const unattended = isUnattended(sourceBotId, sourceThreadId);
3385
+ // The first line of an opened thread is another bot's words: it carries
3386
+ // the same provenance note every relayed peer line does, structurally
3387
+ // (peerAsk) as well as in the text.
3388
+ const peerAsk = openedThreadId && opener
3389
+ ? { botId: opener.id, name: opener.name, unattended: unattended || undefined }
3390
+ : undefined;
3391
+ const text = openedThreadId && opener
3392
+ ? withPeerProvenance(rawText, { botName: opener.name, delivery: "start_thread", unattended })
3393
+ : rawText;
3364
3394
  if (targetThreadId) {
3365
3395
  delegationWatch.set(targetThreadId, {
3366
3396
  channelId: channel?.id,
@@ -3397,7 +3427,8 @@ const runDelegatedTurn = (toBotId, text, commsDepth, sourceThreadId, channel, ta
3397
3427
  return startTurn(toBotId, text, {
3398
3428
  threadId: targetThreadId,
3399
3429
  commsDepth,
3400
- unattended: isUnattended(sourceBotId, sourceThreadId),
3430
+ unattended,
3431
+ peerAsk,
3401
3432
  // startTurn schedules provider/integration setup after marking the bot
3402
3433
  // busy. Those asynchronous setup failures do not emit turn.completed,
3403
3434
  // so clear the watch and report them through this callback too.
@@ -3425,9 +3456,16 @@ function retryDelegationsWaitingOn(botId) {
3425
3456
  // Explicit idle releases (room/setup/reload/watchdog fallbacks) may not
3426
3457
  // publish turn.completed. They free a waiting source continuation too.
3427
3458
  drainDelegationWakes();
3428
- if (store.bot(botId)?.busy)
3459
+ // A bot still busy in one thread has nevertheless freed a slot: the
3460
+ // fresh-thread handoffs waiting on it can move, while the active-thread
3461
+ // ones keep waiting for it to go idle, as they always have.
3462
+ const stillBusy = store.bot(botId)?.busy === true;
3463
+ if (stillBusy && botAtThreadCapacity(botId))
3429
3464
  return;
3430
- for (const waitingThread of releaseDelegationsWaitingOn(botId)) {
3465
+ const released = stillBusy
3466
+ ? releaseDelegationsWaitingOn(botId, (item) => item.targetThreadId !== undefined)
3467
+ : releaseDelegationsWaitingOn(botId);
3468
+ for (const waitingThread of released) {
3431
3469
  drainThreadDelegations(waitingThread);
3432
3470
  }
3433
3471
  });
@@ -3471,13 +3509,15 @@ bus.subscribe((event) => {
3471
3509
  drainDelegationWakes();
3472
3510
  });
3473
3511
  function drainQueuedSends() {
3474
- drainSteeredMessages(store, (botId, threadId, prompt, userMessage, excludeIds) =>
3475
- // A plain attended turn — no automationSource, no unattended, no comms
3476
- // depth: exactly what typing the same words into an idle bot would run.
3512
+ drainSteeredMessages(store, (botId, threadId, prompt, userMessage, excludeIds, unattended) =>
3513
+ // A plain attended turn — no automationSource, no comms depth: exactly
3514
+ // what typing the same words into an idle bot would run. The one
3515
+ // exception is `unattended`: a thread a bot opened on itself while
3516
+ // nobody was watching stays unwatched through the wait for a slot.
3477
3517
  // Drain just appended the held lines; userMessage keeps startTurn
3478
3518
  // from duplicating the last one, and excludeIds drops every drained
3479
3519
  // line from the transcript-replay so they are not also in `prompt`.
3480
- startTurn(botId, prompt, { threadId, userMessage, excludeMessageIds: excludeIds }).then(() => undefined).catch((err) => {
3520
+ startTurn(botId, prompt, { threadId, userMessage, excludeMessageIds: excludeIds, unattended }).then(() => undefined).catch((err) => {
3481
3521
  store.appendMessage(threadId, {
3482
3522
  role: "bot",
3483
3523
  kind: "activity",
@@ -3508,6 +3548,36 @@ async function startOrQueueDirectMessage(botId, threadId, text, replyTo, sendId)
3508
3548
  const message = await startTurn(botId, text, { threadId, replyTo, sendId });
3509
3549
  return { ok: true, threadId, message };
3510
3550
  }
3551
+ /** How many start_thread calls one turn may make. Same spirit as the
3552
+ * create-bot ceiling above: a handful is a plan, more is a fan-out. */
3553
+ const MAX_THREADS_OPENED_PER_TURN = 5;
3554
+ /** A thread a bot opened on itself gets its first turn exactly the way a
3555
+ * person's message would: it runs now if the bot has a free slot, and
3556
+ * otherwise waits in the same composer queue, in line behind whatever the
3557
+ * bot already has waiting. The only inheritance is `unattended` — a bot
3558
+ * nobody is watching does not become watched by opening a thread. */
3559
+ async function startOrQueueOpenedThread(botId, threadId, text, unattended) {
3560
+ // A room turn holds the bot too (startTurn refuses a direct turn during
3561
+ // one); the drain's own block check already waits for it, so the words
3562
+ // queue here rather than bounce.
3563
+ if (botAtThreadCapacity(botId) || activeGroupTurnForBot(botId)) {
3564
+ queueSteeredMessage(botId, threadId, text, { reason: "capacity", unattended });
3565
+ return { state: "queued", position: queuedThreadPosition(botId, threadId) ?? 1 };
3566
+ }
3567
+ try {
3568
+ await startTurn(botId, text, { threadId, unattended });
3569
+ return { state: "running" };
3570
+ }
3571
+ catch (error) {
3572
+ const why = error instanceof Error ? error.message : String(error);
3573
+ store.appendMessage(threadId, {
3574
+ role: "bot",
3575
+ kind: "activity",
3576
+ tool: { name: `error: this thread could not start — ${why.slice(0, 120)}`, ok: false },
3577
+ });
3578
+ return { state: "failed", error: why };
3579
+ }
3580
+ }
3511
3581
  const screenPollers = new Map();
3512
3582
  /** The preview shares the box's single command endpoint with the agent's
3513
3583
  * own actions, so every frame we take is latency stolen from the work the
@@ -3763,6 +3833,10 @@ async function startTurn(botId, text, opts) {
3763
3833
  if (!opts?.cardContinuation) {
3764
3834
  if (commsDepth === 0 && opts?.automationSource === undefined && !opts?.unattended) {
3765
3835
  personAskAt.set(threadId, userMessage.at);
3836
+ // Continuing an old run as a normal conversation makes that task
3837
+ // visible again; these new replies are not routine report updates.
3838
+ if (task.routineRunId)
3839
+ store.patchTask(bot.id, threadId, { routineRunId: undefined });
3766
3840
  }
3767
3841
  else {
3768
3842
  personAskAt.delete(threadId);
@@ -4164,7 +4238,7 @@ async function startTurn(botId, text, opts) {
4164
4238
  // model mostly did not think to make.
4165
4239
  ? peerRosterSystemPrompt(sectionPeers)
4166
4240
  : "";
4167
- const credentialPrompt = integrations.agents ? CREDENTIAL_PROMPT : "";
4241
+ const credentialPrompt = integrations.agents ? CREDENTIAL_PROMPT + THREADS_PROMPT : "";
4168
4242
  const routinePrompt = integrations.agents ? ROUTINE_PROMPT : "";
4169
4243
  const profilePrompt = integrations.agents ? PROFILE_PROMPT : "";
4170
4244
  const recallPrompt = integrations.agents ? SESSION_SEARCH_SYSTEM_PROMPT : "";
@@ -4380,6 +4454,13 @@ async function startTurn(botId, text, opts) {
4380
4454
  // The scheduler owns timing and receipts; the existing harness remains the
4381
4455
  // only owner of provider sessions, approvals, tools, computers and messages.
4382
4456
  function routineSourceOwner(run) {
4457
+ if (run.resultsThreadId) {
4458
+ const bot = store.bot(run.botId);
4459
+ const task = bot && store.taskByThread(bot.id, run.resultsThreadId);
4460
+ return bot && !bot.hidden && task && !task.routineRunId
4461
+ ? { bot, group: undefined, threadId: task.threadId }
4462
+ : null;
4463
+ }
4383
4464
  const threadId = run.sourceThreadId?.trim();
4384
4465
  if (!threadId)
4385
4466
  return null;
@@ -4393,7 +4474,8 @@ function routineSourceOwner(run) {
4393
4474
  // whose conversation held the card. Recheck their section before sharing
4394
4475
  // a result, since either bot may have moved since confirmation.
4395
4476
  const sourceBot = store.botByThread(threadId);
4396
- if (sourceBot && sectionKey(sourceBot.section) === sectionKey(bot.section)) {
4477
+ if (sourceBot && !sourceBot.hidden && !store.taskByThread(sourceBot.id, threadId)?.routineRunId &&
4478
+ sectionKey(sourceBot.section) === sectionKey(bot.section)) {
4397
4479
  return { bot: sourceBot, group: undefined, threadId };
4398
4480
  }
4399
4481
  const group = store.groupByThread(threadId);
@@ -4410,6 +4492,7 @@ function routineRunCard(run) {
4410
4492
  runId: run.id,
4411
4493
  routineId: run.routineId,
4412
4494
  routineName: redactSecretsInText(run.routineName),
4495
+ scheduledFor: run.scheduledFor,
4413
4496
  status: run.status,
4414
4497
  };
4415
4498
  if (run.goalStatus)
@@ -4452,8 +4535,16 @@ function routineRunFallbackText(card) {
4452
4535
  * another chat message. */
4453
4536
  function syncRoutineRunToSource(run) {
4454
4537
  const source = routineSourceOwner(run);
4455
- if (!source)
4538
+ if (!source) {
4539
+ const execution = run.threadId ? store.taskByThread(run.botId, run.threadId) : null;
4540
+ if (execution?.routineRunId === run.id) {
4541
+ store.patchTask(run.botId, execution.threadId, {
4542
+ routineRunId: undefined,
4543
+ ...(["waiting", "completed", "failed", "missed"].includes(run.status) ? { unread: true } : {}),
4544
+ });
4545
+ }
4456
4546
  return null;
4547
+ }
4457
4548
  const sourceThreadId = source.threadId;
4458
4549
  const card = routineRunCard(run);
4459
4550
  const text = routineRunFallbackText(card);
@@ -4474,6 +4565,18 @@ function syncRoutineRunToSource(run) {
4474
4565
  }
4475
4566
  store.appendMessage(sourceThreadId, message);
4476
4567
  }
4568
+ // Mark only the fresh execution, after its first running card persisted
4569
+ // and before dispatch adds any transcript. A user can later promote it by
4570
+ // sending a normal follow-up; replaying/marking the old receipt seen must
4571
+ // never hide that conversation again.
4572
+ if (run.target === "bot" && run.triggerSource !== "webhook" && run.threadId && run.threadId !== sourceThreadId) {
4573
+ const execution = store.taskByThread(run.botId, run.threadId);
4574
+ const freshExecution = execution && run.status === "running" && !execution.routineRunId &&
4575
+ store.messagesFor(run.threadId).length === 0;
4576
+ if (execution && (freshExecution || (execution.routineRunId === run.id && execution.unread))) {
4577
+ store.patchTask(run.botId, run.threadId, { routineRunId: run.id, unread: false });
4578
+ }
4579
+ }
4477
4580
  // Merely queueing/running is ambient progress. Attention and terminal
4478
4581
  // states become unread in the conversation where the user asked for them.
4479
4582
  if (statusChanged && ["waiting", "completed", "failed", "missed"].includes(run.status)) {
@@ -4538,7 +4641,7 @@ async function stopBotForEmergencyApprovalDowngrade(botId) {
4538
4641
  // Load queued handoffs before scheduler recovery can fail an interrupted
4539
4642
  // run. Its failure callback can then durably drop that work immediately;
4540
4643
  // nothing dispatches until the listener is ready below.
4541
- const commsBus = { store, broadcast };
4644
+ const commsBus = { store, broadcast, threadSlotFree: (botId) => !botAtThreadCapacity(botId) };
4542
4645
  _loadPending();
4543
4646
  routines = new RoutineManager({
4544
4647
  emit: broadcast,
@@ -4570,6 +4673,25 @@ routines = new RoutineManager({
4570
4673
  return task;
4571
4674
  },
4572
4675
  createGoalTask: (groupId, title) => store.createGroupTask(groupId, title, false),
4676
+ isResultsThread: (botId, threadId) => {
4677
+ const bot = store.bot(botId);
4678
+ const task = store.taskByThread(botId, threadId);
4679
+ return Boolean(bot && !bot.hidden && task && !task.routineRunId);
4680
+ },
4681
+ resolveResultsThread: (routine, forceNew) => {
4682
+ // A trusted chat source is already snapshotted as sourceThreadId on each
4683
+ // run. Keep it distinct: it can belong to a teammate or room, whereas an
4684
+ // explicit resultsThreadId must be a visible task owned by the running bot.
4685
+ if (!forceNew && routineSourceOwner(routine))
4686
+ return routine.resultsThreadId;
4687
+ return store.createTask(routine.botId, `${routine.name} · Results`, false)?.threadId;
4688
+ },
4689
+ discardResultsThread: (botId, threadId) => {
4690
+ const task = store.taskByThread(botId, threadId);
4691
+ if (task && !task.busy && !task.routineRunId && store.messagesFor(threadId).length === 0) {
4692
+ store.deleteTask(botId, threadId);
4693
+ }
4694
+ },
4573
4695
  startTurn: (botId, threadId, prompt, runOn, triggerSource, onDispatchError) => startTurn(botId, prompt, { threadId, runOn, automationSource: triggerSource, onDispatchError })
4574
4696
  .then(() => undefined),
4575
4697
  startGoal: async (groupId, threadId, prompt, coordinatorBotId, runId, _onDispatchError) => {
@@ -4738,6 +4860,13 @@ const agentRoutine = (routine, latestRun) => {
4738
4860
  type: "interval",
4739
4861
  everyMinutes: routine.schedule.everyMinutes,
4740
4862
  anchorAt: new Date(routine.schedule.anchorAt).toISOString(),
4863
+ ...(routine.schedule.weekdays === undefined
4864
+ ? {}
4865
+ : { weekdays: routine.schedule.weekdays.map((day) => ROUTINE_WEEKDAY_NAMES[day]) }),
4866
+ ...(routine.schedule.window === undefined ? {} : { window: { ...routine.schedule.window } }),
4867
+ ...(routine.schedule.endsAt === undefined
4868
+ ? {}
4869
+ : { endsAt: new Date(routine.schedule.endsAt).toISOString() }),
4741
4870
  }
4742
4871
  : {
4743
4872
  type: "weekly",
@@ -5237,7 +5366,7 @@ setupRetry = 0) {
5237
5366
  readyGroup.bulletin.trim() && `Room bulletin (shared instructions for everyone):\n${readyGroup.bulletin.trim()}`,
5238
5367
  `Reply as yourself, briefly and conversationally. To bring a teammate in, mention them like @Name — they'll see the conversation and respond.`,
5239
5368
  outsideRoom.length > 0 && roomPeerRosterSystemPrompt(outsideRoom),
5240
- integrations.agents && CREDENTIAL_PROMPT.trim(),
5369
+ integrations.agents && (CREDENTIAL_PROMPT + THREADS_PROMPT).trim(),
5241
5370
  integrations.agents && ROUTINE_PROMPT.trim(),
5242
5371
  integrations.agents && PROFILE_PROMPT.trim(),
5243
5372
  skillAuthoring && LEARN_PROMPT.trim(),
@@ -6790,6 +6919,7 @@ function cliProbeEnvironment() {
6790
6919
  "OMB_COMPOSIO_BROKER_TOKEN",
6791
6920
  "OMB_TTS_KEY",
6792
6921
  "OMB_OPENAI_IMAGE_KEY",
6922
+ "OMB_CUSTOM_IMAGE_KEY",
6793
6923
  "ANTHROPIC_API_KEY",
6794
6924
  "OPENAI_API_KEY",
6795
6925
  ]) {
@@ -6902,7 +7032,7 @@ function configStatus() {
6902
7032
  // the chosen voice is a setting, not a secret; the key is reported the
6903
7033
  // same configured-or-not way as every other credential
6904
7034
  tts: tts.describeVoice(cfg),
6905
- imageGen: { configured: Boolean(cfg.imageGen?.key) },
7035
+ imageGen: avatarImageStatus(cfg),
6906
7036
  // not a secret — the sidebar shows it
6907
7037
  profile: { name: cfg.profile?.name ?? "", email: cfg.profile?.email ?? "" },
6908
7038
  // not a secret — the settings picker shows it; "" = follow the system
@@ -7406,6 +7536,7 @@ const handleRequest = async (req, res) => {
7406
7536
  ...parsed.data,
7407
7537
  generation,
7408
7538
  createdBots: 0,
7539
+ openedThreads: 0,
7409
7540
  });
7410
7541
  return json(res, 201, { token });
7411
7542
  }
@@ -7527,6 +7658,82 @@ const handleRequest = async (req, res) => {
7527
7658
  // to keep trying — but it is still NAMED, with the refusal it would
7528
7659
  // have met. Without that the bot can only say it is in no room at
7529
7660
  // all, while the person is looking at it in that very room.
7661
+ // list_threads: the caller's own threads, plus — on every peer it can
7662
+ // reach — only the threads the caller itself opened. A peer's other
7663
+ // threads are its own business (and the person's), so the scope is
7664
+ // "what you started", never "what that bot is doing". State is read
7665
+ // the way the sidebar reads it, so the bot and the person agree.
7666
+ if (method === "GET" && path === "/api/internal/threads") {
7667
+ const from = internalSender;
7668
+ const fromThreadId = internalCapability.threadId;
7669
+ if (!connectorThread(from.id, fromThreadId)) {
7670
+ return json(res, 403, { error: "source conversation does not belong to sender" });
7671
+ }
7672
+ const rows = [];
7673
+ const stateOf = (bot, task) => {
7674
+ if (task.activity === "waiting-on-you")
7675
+ return "waiting-on-you";
7676
+ if (threadBusy(bot.id, task.threadId))
7677
+ return "running";
7678
+ if (queuedThreadPosition(bot.id, task.threadId) !== null)
7679
+ return "queued";
7680
+ return "idle";
7681
+ };
7682
+ for (const task of store.tasks(from.id)) {
7683
+ rows.push({
7684
+ threadId: task.threadId, botId: from.id, botName: from.name, title: task.title,
7685
+ state: stateOf(from, task), unread: task.unread === true, openedAt: task.openedBy?.at ?? task.createdAt,
7686
+ delegationId: task.openedBy?.delegationId, own: true,
7687
+ });
7688
+ }
7689
+ for (const peer of reachablePeers(store.bots, from)) {
7690
+ for (const task of store.tasks(peer.id)) {
7691
+ if (task.openedBy?.botId !== from.id)
7692
+ continue;
7693
+ rows.push({
7694
+ threadId: task.threadId, botId: peer.id, botName: peer.name, title: task.title,
7695
+ state: stateOf(peer, task), unread: task.unread === true, openedAt: task.openedBy.at,
7696
+ delegationId: task.openedBy.delegationId, own: false,
7697
+ });
7698
+ }
7699
+ }
7700
+ rows.sort((a, b) => b.openedAt - a.openedAt);
7701
+ return json(res, 200, { threads: rows.slice(0, 100) });
7702
+ }
7703
+ // close_thread: a bot tidies a thread it opened (or one of its own)
7704
+ // once its result has been read. Closing is the sidebar's idle state
7705
+ // plus a chip saying who closed it — never a deletion, which stays a
7706
+ // person's confirmed action, and never while the thread is running.
7707
+ const closeMatch = method === "POST" ? path.match(/^\/api\/internal\/threads\/([\w-]+)\/close$/) : null;
7708
+ if (closeMatch) {
7709
+ const from = internalSender;
7710
+ const fromThreadId = internalCapability.threadId;
7711
+ if (!connectorThread(from.id, fromThreadId)) {
7712
+ return json(res, 403, { error: "source conversation does not belong to sender" });
7713
+ }
7714
+ const threadId = closeMatch[1];
7715
+ const owner = [from, ...reachablePeers(store.bots, from)].find((bot) => store.taskByThread(bot.id, threadId));
7716
+ const task = owner ? store.taskByThread(owner.id, threadId) : undefined;
7717
+ if (!owner || !task)
7718
+ return json(res, 404, { error: "no such thread — call list_threads for the ones you can see" });
7719
+ if (owner.id !== from.id && task.openedBy?.botId !== from.id) {
7720
+ return json(res, 403, { error: "that thread is not yours to close — only the bot that opened it, or its own bot, can" });
7721
+ }
7722
+ if (threadId === fromThreadId)
7723
+ return json(res, 400, { error: "you cannot close the thread you are speaking in — finish your turn instead" });
7724
+ if (threadBusy(owner.id, threadId)) {
7725
+ return json(res, 409, { error: `#${task.title} is still running — wait for it to finish (list_threads), or the person can stop it from the app` });
7726
+ }
7727
+ store.appendMessage(threadId, {
7728
+ role: "bot",
7729
+ kind: "activity",
7730
+ from: { botId: from.id, name: from.name, color: from.color },
7731
+ tool: { name: `Closed by @${from.name}`, ok: true },
7732
+ });
7733
+ if (task.unread)
7734
+ store.patchTask(owner.id, threadId, { unread: false });
7735
+ return json(res, 200, { closed: true, threadId, title: task.title, botName: owner.name });
7736
+ }
7530
7737
  if (method === "GET" && path === "/api/internal/rooms") {
7531
7738
  const from = internalSender;
7532
7739
  const fromThreadId = internalCapability.threadId;
@@ -8220,6 +8427,128 @@ const handleRequest = async (req, res) => {
8220
8427
  store.appendMessage(fromThreadId, chip);
8221
8428
  return json(res, 201, { ok: true, messageId: posted.id, roomName: room.name });
8222
8429
  }
8430
+ // start_thread: a bot opens a real thread — on itself for separate
8431
+ // work, or on a teammate as a handoff that should run on its own.
8432
+ // Never activates: a bot must not move what the person is looking at.
8433
+ if (method === "POST" && path === "/api/internal/threads") {
8434
+ const body = await readInternalBody();
8435
+ const from = internalSender;
8436
+ const fromThreadId = internalCapability.threadId;
8437
+ const owner = connectorThread(from.id, fromThreadId);
8438
+ if (!owner)
8439
+ return json(res, 403, { error: "source conversation does not belong to sender" });
8440
+ if (body.depth !== undefined &&
8441
+ (!Number.isInteger(body.depth) || body.depth < 0 || body.depth !== internalCapability.depth)) {
8442
+ return json(res, 403, { error: "the recursion depth does not match this turn" });
8443
+ }
8444
+ const title = String(body.title ?? "").trim();
8445
+ const message = String(body.message ?? "").trim();
8446
+ if (!title || !message)
8447
+ return json(res, 400, { error: "title and message are required" });
8448
+ if (title.length > 80)
8449
+ return json(res, 400, { error: "title must be at most 80 characters" });
8450
+ // the title is quoted into chips and the sidebar, one line each
8451
+ if (!fitsOnOneLine(title))
8452
+ return json(res, 400, { error: "title must fit on one line" });
8453
+ if (internalCapability.openedThreads >= MAX_THREADS_OPENED_PER_TURN) {
8454
+ return json(res, 429, { error: `you can open at most ${MAX_THREADS_OPENED_PER_TURN} threads in one turn` });
8455
+ }
8456
+ const toBotId = typeof body.toBotId === "string" && body.toBotId.trim() ? body.toBotId.trim() : from.id;
8457
+ const target = store.bot(toBotId);
8458
+ if (!target)
8459
+ return json(res, 404, { error: "no such bot" });
8460
+ // A folder is the target's own organisation; a name is what the
8461
+ // model has, an id is what the sidebar has, so accept either.
8462
+ const folder = typeof body.folder === "string" ? body.folder.trim() : "";
8463
+ let projectId;
8464
+ if (folder) {
8465
+ const project = (target.projects ?? []).find((candidate) => candidate.id === folder || candidate.name.trim().toLowerCase() === folder.toLowerCase());
8466
+ if (!project) {
8467
+ const names = (target.projects ?? []).map((candidate) => candidate.name).join(", ");
8468
+ return json(res, 400, { error: `@${target.name} has no folder named "${folder}"${names ? ` — the folders are: ${names}` : " — it has no folders; leave folder out"}` });
8469
+ }
8470
+ projectId = project.id;
8471
+ }
8472
+ const sourceTitle = owner.group ? owner.group.name : (store.taskByThread(from.id, fromThreadId)?.title ?? "");
8473
+ if (target.id === from.id) {
8474
+ const task = store.createTask(from.id, title, false, projectId, { botId: from.id, name: from.name, at: Date.now() });
8475
+ if (!task)
8476
+ return json(res, 500, { error: "couldn't create that thread" });
8477
+ internalCapability.openedThreads += 1;
8478
+ const chip = {
8479
+ role: "bot",
8480
+ kind: "activity",
8481
+ tool: { name: `Opened thread #${task.title}`, ok: true },
8482
+ threadRef: { botId: from.id, threadId: task.threadId, title: task.title },
8483
+ };
8484
+ if (owner.group)
8485
+ chip.from = { botId: from.id, name: from.name, color: from.color };
8486
+ store.appendMessage(fromThreadId, chip);
8487
+ // The first line is the bot's own words. Say so where the model
8488
+ // reads it, so a later turn in that thread never mistakes the
8489
+ // request for the person's.
8490
+ const opening = `[Thread you opened yourself${sourceTitle ? ` from #${sourceTitle}` : ""}. The request below is your own words, not the person's: do the work here and end with a clear result they can read.]\n\n${message}`;
8491
+ const outcome = await startOrQueueOpenedThread(from.id, task.threadId, opening, isUnattended(from.id, fromThreadId));
8492
+ return json(res, 201, {
8493
+ threadId: task.threadId,
8494
+ title: task.title,
8495
+ botId: from.id,
8496
+ botName: from.name,
8497
+ self: true,
8498
+ limit: maxConcurrentBotThreads(cfg),
8499
+ ...outcome,
8500
+ });
8501
+ }
8502
+ // A peer thread is a handoff into a fresh thread: every gate the
8503
+ // classic handoff has applies unchanged, here at queue time and
8504
+ // again in the drain at dispatch time.
8505
+ const depth = internalCapability.depth;
8506
+ if (depth >= MAX_COMMS_DEPTH) {
8507
+ return json(res, 200, { error: "thread chains are limited to one hop — open the thread on yourself, or do this one here" });
8508
+ }
8509
+ if (sectionKey(from.section) !== sectionKey(target.section)) {
8510
+ return json(res, 403, { error: "that bot belongs to a different section" });
8511
+ }
8512
+ if (!peerAllowed(from, target.id)) {
8513
+ return json(res, 403, { error: "that bot is not on this bot's allowed peers — call list_bots for the ones you can reach" });
8514
+ }
8515
+ const task = store.createTask(target.id, title, false, projectId, { botId: from.id, name: from.name, at: Date.now() });
8516
+ if (!task)
8517
+ return json(res, 500, { error: "couldn't create that thread" });
8518
+ const queued = queueDelegation(commsBus, from, { toBotId: target.id, message, depth, targetThreadId: task.threadId }, MAX_COMMS_DEPTH, fromThreadId);
8519
+ if (queued.result !== "ok" || !queued.id) {
8520
+ // nothing will ever run there: take the row back before the person
8521
+ // sees a thread that goes nowhere
8522
+ store.deleteTask(target.id, task.threadId);
8523
+ const said = {
8524
+ self: "a bot cannot hand a thread to itself this way — leave bot_id out",
8525
+ too_deep: "thread chains are limited to one hop — open the thread on yourself, or do this one here",
8526
+ no_target: "no such bot",
8527
+ too_many: "too many handoffs queued on this turn — finish your turn and open the rest next time",
8528
+ };
8529
+ return json(res, 200, { error: said[queued.result === "ok" ? "no_target" : queued.result] });
8530
+ }
8531
+ store.setTaskOpenedBy(target.id, task.threadId, { botId: from.id, name: from.name, delegationId: queued.id, at: task.openedBy?.at ?? Date.now() });
8532
+ internalCapability.openedThreads += 1;
8533
+ // An honest forecast, not a promise: the handoff starts when this
8534
+ // turn ends, and by then the target's slots are taken by whatever is
8535
+ // running there plus the threads this turn already opened ahead.
8536
+ const limit = maxConcurrentBotThreads(cfg);
8537
+ const running = store.tasks(target.id).filter((candidate) => threadBusy(target.id, candidate.threadId)).length;
8538
+ const ahead = pendingDelegationSnapshot().filter((pending) => pending.toBotId === target.id && pending.targetThreadId !== undefined && pending.targetThreadId !== task.threadId).length;
8539
+ const position = running + ahead + 1;
8540
+ return json(res, 201, {
8541
+ threadId: task.threadId,
8542
+ title: task.title,
8543
+ botId: target.id,
8544
+ botName: target.name,
8545
+ self: false,
8546
+ delegationId: queued.id,
8547
+ approvalRequired: Boolean(from.approvePeerComms),
8548
+ limit,
8549
+ ...(position > limit ? { state: "queued", position: position - limit } : { state: "pending" }),
8550
+ });
8551
+ }
8223
8552
  if (method === "POST" && path === "/api/internal/create-bot") {
8224
8553
  const body = await readInternalBody();
8225
8554
  const chief = internalSender;
@@ -8732,6 +9061,7 @@ const handleRequest = async (req, res) => {
8732
9061
  return json(res, 400, { error: "messages must be a non-negative whole number" });
8733
9062
  return json(res, 200, {
8734
9063
  bots: store.bots.map((bot) => ({ ...publicBot(bot), ...messagePage(bot.threadId, limit) })),
9064
+ botQueuedMessages: publicBotQueuedMessages(),
8735
9065
  groups: store.groups.map((g) => ({ ...publicGroupState(g), ...messagePage(g.threadId, limit) })),
8736
9066
  computerControl: Object.fromEntries(store.bots.map((bot) => {
8737
9067
  const snapshot = computerControl.snapshot(bot.id);
@@ -10024,7 +10354,7 @@ const handleRequest = async (req, res) => {
10024
10354
  if (!parsed.success) {
10025
10355
  return json(res, 400, { error: `prompt must be at most 400 characters` });
10026
10356
  }
10027
- const generated = await generateAvatarImage(cfg.imageGen?.key ?? "", existing, parsed.data.prompt);
10357
+ const generated = await generateAvatarImage(cfg, existing, parsed.data.prompt);
10028
10358
  const current = store.bot(existing.id);
10029
10359
  if (!current)
10030
10360
  return json(res, 404, { error: "no such bot" });
@@ -12005,7 +12335,7 @@ const handleRequest = async (req, res) => {
12005
12335
  const state = await providerAuthSessions.status(authStatus[1], auth.kind === "session" ? auth.session.id : "loopback", url.searchParams.get("flowId") ?? "");
12006
12336
  return json(res, 200, { auth: state });
12007
12337
  }
12008
- const instanceAction = /^\/api\/instances\/([\w.-]+)\/(refresh-models|install|auth\/start|auth\/complete|auth\/cancel)$/.exec(path);
12338
+ const instanceAction = /^\/api\/instances\/([\w.-]+)\/(refresh-models|install|auth\/start|auth\/complete|auth\/cancel|auth\/sign-out)$/.exec(path);
12009
12339
  if (method === "POST" && instanceAction) {
12010
12340
  if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
12011
12341
  return json(res, 415, { error: "content-type must be application/json" });
@@ -12023,7 +12353,7 @@ const handleRequest = async (req, res) => {
12023
12353
  }
12024
12354
  if (action === "install") {
12025
12355
  if (!(await registry.installRuntime(instanceId)))
12026
- return json(res, 404, { error: "managed installation is unavailable" });
12356
+ return json(res, 404, { error: "Installing this engine from Settings is not available on this server. Use the install command on the machine running OpenMausBot." });
12027
12357
  return json(res, 200, { instances: await describeInstances() });
12028
12358
  }
12029
12359
  if (action === "auth/start") {
@@ -12038,6 +12368,13 @@ const handleRequest = async (req, res) => {
12038
12368
  }
12039
12369
  return json(res, 200, { auth: started });
12040
12370
  }
12371
+ if (action === "auth/sign-out") {
12372
+ const instance = registry.get(instanceId);
12373
+ if (!instance)
12374
+ return json(res, 404, { error: "unknown instance" });
12375
+ await providerAuthSessions.signOut(instance, owner);
12376
+ return json(res, 200, { instances: await describeInstances() });
12377
+ }
12041
12378
  if (action === "auth/complete") {
12042
12379
  const body = await readBody(req);
12043
12380
  const flowId = typeof body?.flowId === "string" ? body.flowId : "";
@@ -12613,6 +12950,8 @@ const handleRequest = async (req, res) => {
12613
12950
  persisted.tts.key = "";
12614
12951
  if (persisted.imageGen?.key !== undefined)
12615
12952
  persisted.imageGen.key = "";
12953
+ if (persisted.imageGen?.customApiKey !== undefined)
12954
+ persisted.imageGen.customApiKey = "";
12616
12955
  saveConfig(persisted);
12617
12956
  configWriteCommitted = true;
12618
12957
  syncCredentialEnv(patch);