@chibitek/chibi 0.1.155 → 0.1.157

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 (91) hide show
  1. package/dist/assets/{abnfDiagram-O67JEVCF-B8LdsCr1.js → abnfDiagram-O67JEVCF-BkYbog6D.js} +1 -1
  2. package/dist/assets/{arc-DU5fSmKU.js → arc-QwTuMsxp.js} +1 -1
  3. package/dist/assets/{architectureDiagram-NJMV4G6O-DdQSIFKv.js → architectureDiagram-NJMV4G6O-GdicO2PQ.js} +1 -1
  4. package/dist/assets/{blockDiagram-BEXU5L5S-CaPcGYSS.js → blockDiagram-BEXU5L5S-Dkr29TFF.js} +1 -1
  5. package/dist/assets/{c4Diagram-YGBWAQC7-unt0ePgO.js → c4Diagram-YGBWAQC7-Dz_3dstx.js} +1 -1
  6. package/dist/assets/channel-D2y0i2ps.js +1 -0
  7. package/dist/assets/{chunk-2Q5K7J3B-Babg5Fzu.js → chunk-2Q5K7J3B-CGsfu-PI.js} +1 -1
  8. package/dist/assets/{chunk-3FUC2YCW-D1-dWVHo.js → chunk-3FUC2YCW-CDqI37_6.js} +2 -2
  9. package/dist/assets/{chunk-5VM5RSS4-DU7nSMbT.js → chunk-5VM5RSS4-DnmSuyTb.js} +1 -1
  10. package/dist/assets/{chunk-GTNCS2PH-QIjCJIhV.js → chunk-GTNCS2PH-Dh7tXcJT.js} +1 -1
  11. package/dist/assets/{chunk-GWA4HPMP-pSByUE5R.js → chunk-GWA4HPMP-D_SM1ZrY.js} +1 -1
  12. package/dist/assets/{chunk-JWPE2WC7-Dj2AaR1b.js → chunk-JWPE2WC7-DmeMuTsI.js} +1 -1
  13. package/dist/assets/{chunk-NETBCI7D-pZbN0-FP.js → chunk-NETBCI7D-R1UT9b5f.js} +1 -1
  14. package/dist/assets/{chunk-WEXAMYUT-Bqarwe4D.js → chunk-WEXAMYUT-DpPfU1yO.js} +1 -1
  15. package/dist/assets/{chunk-XXDRQBXY-WV8lm3aa.js → chunk-XXDRQBXY-DlihfTkM.js} +1 -1
  16. package/dist/assets/{classDiagram-v2-NBCMYWYE-BANBtky1.js → classDiagram-v2-NBCMYWYE-BlTV6lSC.js} +1 -1
  17. package/dist/assets/{cose-bilkent-JH36ORCC-CtrktgBC.js → cose-bilkent-JH36ORCC-Br1kPFWw.js} +1 -1
  18. package/dist/assets/{cynefin-OW5HDTMX-DnhVVfba.js → cynefin-OW5HDTMX-BqPp3ZMd.js} +1 -1
  19. package/dist/assets/{cynefinDiagram-VND7K2PF-B42SRR6x.js → cynefinDiagram-VND7K2PF-DFfs_k8-.js} +1 -1
  20. package/dist/assets/{dagre-6A5THRUB-DM--vFMK.js → dagre-6A5THRUB-Ch3xIJA7.js} +1 -1
  21. package/dist/assets/{diagram-22UHCM2B-ZBupBrPU.js → diagram-22UHCM2B-DIHUD87N.js} +1 -1
  22. package/dist/assets/{diagram-3UASUU5V-BPQzNIOd.js → diagram-3UASUU5V-CAgQQdeF.js} +1 -1
  23. package/dist/assets/{diagram-ATOU4E4O-5kO8vf8Z.js → diagram-ATOU4E4O-9HVKodrI.js} +1 -1
  24. package/dist/assets/{diagram-CDSNMT55-DtxogB86.js → diagram-CDSNMT55-DU2kDq_h.js} +1 -1
  25. package/dist/assets/{diagram-MLGK6HIB-Co9-nYw_.js → diagram-MLGK6HIB-D-qTAhHD.js} +1 -1
  26. package/dist/assets/{diagram-MPIPVDR6-DWRuMh5y.js → diagram-MPIPVDR6-C31SnI9P.js} +1 -1
  27. package/dist/assets/{ebnfDiagram-ZINNZB2B-DU_nmoG9.js → ebnfDiagram-ZINNZB2B-BHrdiBVd.js} +1 -1
  28. package/dist/assets/{elk-276RUBZZ-Bcje7S94.js → elk-276RUBZZ-DWyljRo5.js} +1 -1
  29. package/dist/assets/{erDiagram-OPXOYQCR-9aCo_2CI.js → erDiagram-OPXOYQCR-DyY59XPp.js} +1 -1
  30. package/dist/assets/{flowDiagram-KWPJA3E3-D4FYFyc5.js → flowDiagram-KWPJA3E3-BevMIeAy.js} +1 -1
  31. package/dist/assets/{ganttDiagram-FUAMR5RP-WQHX5rbx.js → ganttDiagram-FUAMR5RP-Ct18WHf4.js} +1 -1
  32. package/dist/assets/{gitGraphDiagram-X574FWY7-DRyzBdiV.js → gitGraphDiagram-X574FWY7-yhixO1N3.js} +1 -1
  33. package/dist/assets/{index-CG1OvRgO.js → index-BEJ8EKQt.js} +1 -1
  34. package/dist/assets/index-DGWeJcck.css +1 -0
  35. package/dist/assets/{index-D_8SD_0K.js → index-ziyH_uer.js} +170 -168
  36. package/dist/assets/{infoDiagram-VRGFBTTK-D7SOmp4n.js → infoDiagram-VRGFBTTK-CYa-r_S5.js} +1 -1
  37. package/dist/assets/{ishikawaDiagram-OU5B5YK6-CdxOnDVX.js → ishikawaDiagram-OU5B5YK6-CnxwM7gY.js} +1 -1
  38. package/dist/assets/{journeyDiagram-ZHPQQLJL-CH7477Oa.js → journeyDiagram-ZHPQQLJL-RKQfXBrp.js} +1 -1
  39. package/dist/assets/{kanban-definition-PNTS6WVX-BdpVvkHd.js → kanban-definition-PNTS6WVX-CSlRDIF6.js} +1 -1
  40. package/dist/assets/{linear-DTGq6qn5.js → linear-Ch19KVyt.js} +1 -1
  41. package/dist/assets/{mermaid.core-Sn2sv0Ti.js → mermaid.core-Bek7I_t6.js} +5 -5
  42. package/dist/assets/{mindmap-definition-NLK3R4M7-koCmkYuq.js → mindmap-definition-NLK3R4M7-B_LiP8su.js} +1 -1
  43. package/dist/assets/{pegDiagram-GJSIUBJH-C3sqhm8e.js → pegDiagram-GJSIUBJH-DhzQeiZf.js} +1 -1
  44. package/dist/assets/{pieDiagram-5QR66LMP-BELBK122.js → pieDiagram-5QR66LMP-D-BYNKHS.js} +1 -1
  45. package/dist/assets/{quadrantDiagram-O4NWA36T-Cksi5LSm.js → quadrantDiagram-O4NWA36T-DnCm0heP.js} +1 -1
  46. package/dist/assets/{railroadDiagram-XR7U4H2S-f1av_EZW.js → railroadDiagram-XR7U4H2S-COqgYnhn.js} +1 -1
  47. package/dist/assets/{requirementDiagram-PLB6GJNP-CW4O5nER.js → requirementDiagram-PLB6GJNP-Dvt72ajf.js} +1 -1
  48. package/dist/assets/{sankeyDiagram-IPEJSGJF-Dz8ADHNY.js → sankeyDiagram-IPEJSGJF-CisC9nbp.js} +1 -1
  49. package/dist/assets/{sequenceDiagram-PO4LG4MO-Cc3HE0iZ.js → sequenceDiagram-PO4LG4MO-BO8xqvxa.js} +1 -1
  50. package/dist/assets/{sizeCapture-INFHLROL-gdaZNHnK.js → sizeCapture-INFHLROL-DLk1jBzf.js} +1 -1
  51. package/dist/assets/{stateDiagram-v2-GCMORJYK--u8LCnab.js → stateDiagram-v2-GCMORJYK-C_wutSE7.js} +1 -1
  52. package/dist/assets/{swimlanes-2SLR337P-4MAw4kdO.js → swimlanes-2SLR337P-hqo1UlEi.js} +1 -1
  53. package/dist/assets/swimlanesDiagram-TC7HE7FX-D15JqTL9.js +8 -0
  54. package/dist/assets/{timeline-definition-EJHVYXUP-CL89YkCT.js → timeline-definition-EJHVYXUP-C_DJldPg.js} +1 -1
  55. package/dist/assets/{usecaseDiagram-POWQR4AR-C92l7YQe.js → usecaseDiagram-POWQR4AR-CsidVXMA.js} +1 -1
  56. package/dist/assets/{vennDiagram-UO4OBE2U-17siYhSj.js → vennDiagram-UO4OBE2U-Bs-IIqn_.js} +1 -1
  57. package/dist/assets/{wardleyDiagram-VNRHLVJA-CNwa9VmN.js → wardleyDiagram-VNRHLVJA-CXEUyxaj.js} +1 -1
  58. package/dist/assets/{xychartDiagram-PMCCYNJV-Dnu6Xzih.js → xychartDiagram-PMCCYNJV-B9qvmm4P.js} +1 -1
  59. package/dist/index.html +2 -2
  60. package/dist-server/container-mcp.js +81 -81
  61. package/dist-server/drivers/agents-proxy.js +81 -81
  62. package/dist-server/index.js +2354 -1729
  63. package/dist-server/openmausbot.js +676 -643
  64. package/dist-server/pair-cli.js +676 -643
  65. package/dist-server/server/app-installers.js +107 -7
  66. package/dist-server/server/cli-help.js +74 -0
  67. package/dist-server/server/cli.js +32 -74
  68. package/dist-server/server/device-routing.js +60 -0
  69. package/dist-server/server/drivers/claude.js +13 -11
  70. package/dist-server/server/drivers/local-inject.js +3 -4
  71. package/dist-server/server/engine-tiers.js +5 -14
  72. package/dist-server/server/index.js +84 -104
  73. package/dist-server/server/personal-goal-runtime.js +86 -0
  74. package/dist-server/server/personal-goal-turn.js +125 -0
  75. package/dist-server/server/personal-goals.js +195 -0
  76. package/dist-server/server/routes/local-models.js +3 -2
  77. package/dist-server/server/skill-fetch.js +14 -0
  78. package/dist-server/shared/engine-choices.js +15 -0
  79. package/dist-server/shared/engine-tiers.js +74 -2
  80. package/dist-server/shared/local-models.js +1 -3
  81. package/dist-server/shared/personal-goal.js +43 -0
  82. package/dist-server/vps-container-mcp.js +81 -81
  83. package/package.json +1 -1
  84. package/dist/assets/channel-GBaPOaz-.js +0 -1
  85. package/dist/assets/index-C8YP7Zxj.css +0 -1
  86. package/dist/assets/swimlanesDiagram-TC7HE7FX-CamSa0NE.js +0 -8
  87. package/dist-server/connector-proxy.js +0 -205
  88. package/dist-server/server/composio.js +0 -1051
  89. package/dist-server/server/connector-proxy.js +0 -249
  90. package/dist-server/server/mcp-public-registry.js +0 -325
  91. package/dist-server/shared/connector-availability.js +0 -12
@@ -1,3 +1,7 @@
1
+ import { hasUnboundGoalGuard, goalGuardForEvent, addGoalGuard, removeGoalGuard, runPersonalGoalTurn, updatePersonalGoalReceipt } from "./personal-goal-turn.js";
2
+ import { PersonalGoalRuntime, receiveGoalCommand } from "./personal-goal-runtime.js";
3
+ import { personalGoalInstructions } from "./personal-goals.js";
4
+ import { parseGoalCommand } from "../shared/personal-goal.js";
1
5
  // OpenMausBot server — the harness host. Clients hold no transports
2
6
  // (upstream rule): the React app dispatches typed commands over HTTP and
3
7
  // folds one SSE event stream; every provider process runs here.
@@ -143,7 +147,7 @@ import { MEMORY_INDEX, MemoryStoreError, memoryCapacity, memoryOverview, openFol
143
147
  import { beginMemoryTurn, endMemoryTurn, flushAllMemoryJournals, flushMemoryJournal, journalMemoryDelete, journalMemoryWrite, readMemoryJournal, revertMemoryChange, } from "./memory-journal.js";
144
148
  import { readSectionContext, readSections, sectionContextKey, sectionContextLabel, sectionContextSystemPrompt, writeSectionContext, SECTION_CONTEXT_MAX_BYTES, } from "./section-context.js";
145
149
  import { applyStagedSkillWrite, applySkillWriteWithReceipt, getStagedSkillWrite, installSkill, parseSkillMd, scanSkillText, listSkills, listStagedSkillWrites, isSkillName, readSkillFile, rejectStagedSkillWrite, removeSkill, setSkillEnabled, skillsSystemPrompt, stageSkillWrite, } from "./skills.js";
146
- import { fetchSkillFromSource } from "./skill-fetch.js";
150
+ import { resolveSkillImport, skillImportSchema } from "./skill-fetch.js";
147
151
  import { pickBotName } from "./names.js";
148
152
  import { botSkillTemplateSchema, newBotDefaultsSchema, resolveBotCreationDefaults } from "./new-bot-defaults.js";
149
153
  import { expandLearnTurnText, learnSource } from "./skill-learn.js";
@@ -235,6 +239,7 @@ import { activityCsv, activityEntries, appendAdminAction, botAuditSnapshot, botC
235
239
  import { frameForMember, memberBody, memberBot, noteSeen, notFoundFor, audienceEquals, memberGroup, narrowestAudience, parseVisibility, pathSubject, roomFeeds, routineVisible, sameAudience, SEES_EVERYTHING, SEES_ALL_BUT_PRIVATE, VisibleSet, } from "./bot-visibility.js";
236
240
  import { describeBrand, loadBrand } from "./brand.js";
237
241
  import { deliverSseFrame } from "./sse-fanout.js";
242
+ import { DeviceRouteRegistry, removeStream } from "./device-routing.js";
238
243
  import { PHONE_SECRET_PROTOCOL_VERSION, PhoneSecretBridge, PhoneSecretError, PhoneSecretSubmissionRegistry, assertPhoneSecretRequestMatches, phoneSecretOperationId, } from "./phone-secret.js";
239
244
  // Keep these two last: a route module may import any server module, and
240
245
  // loading the table after everything above leaves module start-up order as is.
@@ -248,7 +253,7 @@ import { createModelLevelsRoutes } from "./routes/model-levels.js";
248
253
  import { createMcpMarketplaceRoutes } from "./routes/mcp-marketplace.js";
249
254
  import { createLocalModelRoutes, OLLAMA_LOCAL_URL, ollamaInstalledHere } from "./routes/local-models.js";
250
255
  import { createSetupRoutes } from "./routes/setup.js";
251
- import { installOllamaApp, openPodmanInstaller, runTool, startPodmanMachine } from "./app-installers.js";
256
+ import { installOllamaApp, openPodmanInstaller, startOllamaApp, runTool, startPodmanMachine } from "./app-installers.js";
252
257
  import { CliLoginRunner } from "./cli-login.js";
253
258
  import { JobFailure, SetupJobs } from "./setup-jobs.js";
254
259
  import { localHost, probeLocalInjects } from "./drivers/local-inject.js";
@@ -1820,6 +1825,7 @@ function requestedTaskBot(botId, rawThreadId) {
1820
1825
  return task;
1821
1826
  }
1822
1827
  async function interruptDirectThread(botId, threadId) {
1828
+ personalGoals.steer(threadId);
1823
1829
  const requestOwner = directRequestOwners.get(threadId);
1824
1830
  if (requestOwner) {
1825
1831
  requestOwner.stopped = true;
@@ -3350,7 +3356,7 @@ function localModeTurn(resolution, turn) {
3350
3356
  return { ...resolution, local: true, choice: { ...choice, selection: placement.selection, candidate: placement.candidate } };
3351
3357
  }
3352
3358
  const payPerUsePausedLine = (until) => `Pay-per-use is paused until ${restTime(until)}, when a subscription is back, so this turn stopped.`;
3353
- /** A model this machine serves: never a stand-in for Engineering and above. */
3359
+ /** A model this machine serves: a rung when a ladder names it, else local rules only. */
3354
3360
  const isLocalModel = (candidate, model) => localModel(candidate, model) || modelLevels.classOf(candidate.driverKind, model)?.local === true;
3355
3361
  // ── silent automatic failover (docs/chibitek/engine-tiers.md, step 2) ────
3356
3362
  // Resting engines are shared by every bot and survive a restart.
@@ -3589,10 +3595,7 @@ const eventBase = (event) => ({
3589
3595
  ...(event.providerInstanceId ? { providerInstanceId: event.providerInstanceId } : {}), ...(event.turnId ? { turnId: event.turnId } : {}),
3590
3596
  });
3591
3597
  const restTime = (at) => new Date(at).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
3592
- /** A watched turn ended failed because its engine ran out or was signed
3593
- * out. Rest the engine, then run the same turn on the next engine at the
3594
- * same level, silently; or, when nothing is left, stop it with one line
3595
- * naming the resting engines. Returns what subscribers see. */
3598
+ /** Rest a refused engine and continue on an eligible engine, or show an actionable line. */
3596
3599
  function engineFailover(turn, completion) {
3597
3600
  const failure = turn.failure;
3598
3601
  const record = failoverTurns.get(turn.threadId);
@@ -3615,17 +3618,14 @@ function engineFailover(turn, completion) {
3615
3618
  const restFailed = () => engineRests.rest(turn.instanceId, failedCandidate ? engineAccount(failedCandidate) : undefined, failure.kind, { resetAt: failure.resetAt, resetText: failure.resetText, windowMs: failure.windowMs, message: redactSecretsInText(failure.message),
3616
3619
  ...(failure.permanent ? { permanent: true } : {}), ...(scope ? { scope } : {}), ...(ran?.model ? { model: ran.model } : {}),
3617
3620
  ...(failure.kind === "auth_failed" && failedCandidate ? { reportedSignedIn: failedCandidate.snapshot.state === "available" && failedCandidate.snapshot.authenticated === true } : {}) });
3618
- // An unrecognised failure (provider_error) is only a reason to try the
3619
- // next engine: the engine rests (the circuit breaker) only once the turn
3620
- // really moves, and with nowhere to go the turn ends with the engine's own
3621
- // report, exactly as before, rather than a "resting" line about it.
3621
+ // Unknown failures rest only when the turn moves; otherwise preserve their report.
3622
3622
  const rest = failure.kind === "provider_error" ? undefined : restFailed();
3623
3623
  const label = (instanceId) => candidates.find((candidate) => candidate.instanceId === instanceId)?.displayName ?? instanceId;
3624
3624
  // The one plain line, never the provider's own words: which account needs
3625
3625
  // the person and where to fix it, or that every engine is resting.
3626
3626
  const stop = (line, category = "failover_exhausted") => [
3627
3627
  { ...eventBase(completion), type: "runtime.error", message: line, terminal: true, category, ...(failure.setup ? { setup: true } : {}) },
3628
- completion,
3628
+ failure.kind === "auth_failed" ? { ...completion, stopReason: "auth_required" } : completion,
3629
3629
  ];
3630
3630
  const restingNamed = (instanceIds) => [...new Set(instanceIds)].flatMap((instanceId) => {
3631
3631
  const candidate = candidates.find((entry) => entry.instanceId === instanceId);
@@ -3648,13 +3648,11 @@ function engineFailover(turn, completion) {
3648
3648
  return stop(pick.line, pick.category);
3649
3649
  const { choice } = pick;
3650
3650
  const tried = new Set(record.tried);
3651
- // Move the turn. The attempt that failed is retired quietly afterwards: no
3652
- // chip, no notification, no incident, and nothing settled that the same
3653
- // turn still owns.
3651
+ // Retire the failed attempt without settling the request that the next engine owns.
3654
3652
  const moved = ran ?? record.selection;
3655
3653
  const failedOwner = turnResourceOwners.get(turn.threadId);
3656
3654
  const tokens = completion.usage ?? turnUsage.get(turn.threadId);
3657
- const partialText = turn.partial.trim();
3655
+ const partialText = groupGoalCoordinatorTurns.get(turn.threadId)?.size ? parseGroupGoalDecision(turn.partial).visibleText : turn.partial.trim();
3658
3656
  const requestOwner = directRequestOwners.get(turn.threadId);
3659
3657
  if (partialText) {
3660
3658
  // The answer it was in the middle of stays: the person saw it stream.
@@ -3666,6 +3664,12 @@ function engineFailover(turn, completion) {
3666
3664
  const tools = earlier.filter((message) => message.kind === "activity" && message.tool?.name).map((message) => message.tool.name);
3667
3665
  const note = continuationNote(said.map((message) => message.text ?? ""), tools);
3668
3666
  const attempt = record.attempt + 1;
3667
+ for (const guard of groupGoalCoordinatorTurns.get(turn.threadId) ?? []) {
3668
+ if (!guard.discard && (guard.turnId === completion.turnId || guard.turnId === turn.turnId)) {
3669
+ guard.turnId = undefined;
3670
+ guard.assistantItems = [];
3671
+ }
3672
+ }
3669
3673
  const rerun = startTurn(turn.botId, note ? `${record.text}\n\n${note}` : record.text, {
3670
3674
  ...record.opts,
3671
3675
  threadId: turn.threadId,
@@ -3703,6 +3707,11 @@ function engineFailover(turn, completion) {
3703
3707
  });
3704
3708
  }
3705
3709
  retireFailedAttempt(turn.botId, turn.threadId, turn.generation, failedOwner, completion);
3710
+ if (failure.kind === "auth_failed")
3711
+ reportIncident({
3712
+ kind: "engine-fallback", bot: profile, threadId: turn.threadId, notifyChief: true,
3713
+ detail: `${engineLabel(candidates, moved)} needs signing in again in Settings > Engines; the turn continued on ${engineLabel(candidates, choice.selection)}`,
3714
+ });
3706
3715
  console.error(`[omb-failover] thread=${turn.threadId} ${failure.kind}: ${moved.instanceId} -> ${choice.selection.instanceId} (attempt ${attempt})`);
3707
3716
  // No turnId: the failed provider turn is retired, and its id would hide
3708
3717
  // this record from every window.
@@ -5013,67 +5022,21 @@ const publicBotQueuedMessages = () => queuedSteerSnapshot((botId, threadId) => B
5013
5022
  const groupTurnOperations = new Map();
5014
5023
  const groupGoalCoordinatorTurns = new Map();
5015
5024
  const GROUP_GOAL_COORDINATOR_GUARD_MS = 5 * 60_000;
5016
- function addGroupGoalCoordinatorTurn(threadId, turn) {
5017
- const turns = groupGoalCoordinatorTurns.get(threadId) ?? new Set();
5018
- turns.add(turn);
5019
- groupGoalCoordinatorTurns.set(threadId, turns);
5020
- }
5021
- function removeGroupGoalCoordinatorTurn(threadId, turn) {
5022
- if (turn.cleanupTimer)
5023
- clearTimeout(turn.cleanupTimer);
5024
- const turns = groupGoalCoordinatorTurns.get(threadId);
5025
- turns?.delete(turn);
5026
- if (turns?.size === 0)
5027
- groupGoalCoordinatorTurns.delete(threadId);
5028
- }
5029
- function hasUnboundDiscardedGroupGoalTurn(threadId) {
5030
- return [...(groupGoalCoordinatorTurns.get(threadId) ?? [])]
5031
- .some((turn) => turn.discard && !turn.turnId);
5032
- }
5033
- /** Match private coordinator output to one provider turn, never merely to a
5034
- * reusable room thread. Most adapters emit turn.started before sendTurn
5035
- * resolves, so the first stable event may bind an otherwise pending guard. */
5036
- function groupGoalCoordinatorTurnForEvent(event) {
5037
- const turns = groupGoalCoordinatorTurns.get(event.threadId);
5038
- if (!turns?.size)
5039
- return undefined;
5040
- const candidates = [...turns];
5041
- if (event.turnId) {
5042
- const exact = candidates.find((turn) => turn.turnId === event.turnId);
5043
- if (exact)
5044
- return exact;
5045
- // Until an interrupted handshake returns its own id, no new id can be
5046
- // attributed safely. The stall fallback keeps this thread unavailable in
5047
- // that narrow window; private text is suppressed below until sendTurn's
5048
- // result binds the old guard or its bounded expiry releases ownership.
5049
- const unboundDiscarded = candidates.filter((turn) => turn.discard && !turn.turnId);
5050
- if (unboundDiscarded.length > 0) {
5051
- // The ownership fallback below keeps a lone abandoned handshake's
5052
- // thread closed, so its first eventual id can safely bind here. More
5053
- // than one unbound candidate is genuinely ambiguous and stays gated.
5054
- if (candidates.length === 1) {
5055
- unboundDiscarded[0].turnId = event.turnId;
5056
- // This event is the first stable identity for an already-abandoned
5057
- // provider turn. Tombstone it immediately so this event and every
5058
- // later completion/request cannot settle a replacement on the same
5059
- // room thread.
5060
- retireProviderTurn(event.turnId);
5061
- return unboundDiscarded[0];
5062
- }
5063
- return undefined;
5064
- }
5065
- const pending = candidates.findLast((turn) => !turn.turnId && !turn.discard);
5066
- if (pending && !pending.turnId) {
5067
- pending.turnId = event.turnId;
5068
- return pending;
5069
- }
5070
- return undefined;
5071
- }
5072
- // Turn-scoped events normally carry an id. If an adapter omits it, fail
5073
- // closed for private text; with multiple overlapping guards there is no
5074
- // safe way to attribute a completion, so leave cleanup to the bounded timer.
5075
- return candidates.length === 1 ? candidates[0] : undefined;
5076
- }
5025
+ const personalGoals = new PersonalGoalRuntime(join(DATA_DIR, "personal-goals.json"), {
5026
+ busy: (botId, threadId) => threadBusy(botId, threadId) || Boolean(activeGroupTurnForBot(botId)),
5027
+ interrupt: async (botId, threadId, goalId) => {
5028
+ const owns = [...(groupGoalCoordinatorTurns.get(threadId) ?? [])].some(guard => guard.personalGoalId === goalId && !guard.discard && (!guard.turnId || guard.turnId === liveTurnByThread.get(threadId)));
5029
+ if (owns)
5030
+ await interruptDirectThread(botId, threadId);
5031
+ },
5032
+ changed: (goal) => updatePersonalGoalReceipt(goal, store),
5033
+ run: (goal) => runPersonalGoalTurn(goal, { store, addGuard: addGroupGoalCoordinatorTurn, removeGuard: removeGroupGoalCoordinatorTurn,
5034
+ subscribe: (listener) => bus.subscribe(listener), ignore: shouldIgnoreProviderEvent, busy: threadBusy, start: startTurn }),
5035
+ });
5036
+ const addGroupGoalCoordinatorTurn = (threadId, turn) => addGoalGuard(groupGoalCoordinatorTurns, threadId, turn);
5037
+ const removeGroupGoalCoordinatorTurn = (threadId, turn) => removeGoalGuard(groupGoalCoordinatorTurns, threadId, turn);
5038
+ const hasUnboundDiscardedGroupGoalTurn = (threadId) => hasUnboundGoalGuard(groupGoalCoordinatorTurns, threadId);
5039
+ const groupGoalCoordinatorTurnForEvent = (event) => goalGuardForEvent(groupGoalCoordinatorTurns, retireProviderTurn, event);
5077
5040
  function groupIsWorking(group) {
5078
5041
  return Boolean(group.busyBotId) || Boolean(groupTurnOperations.get(group.id)?.size);
5079
5042
  }
@@ -6024,6 +5987,9 @@ function messageWindow(threadId, messageId, limit) {
6024
5987
  return { messages: all.slice(start, stop).map(slimMessage), hasMore: start > 0 };
6025
5988
  }
6026
5989
  const sseClients = new Set();
5990
+ // Approval device routing: docs/specs/remote-approval/spec.md.
5991
+ const deviceRoutes = new DeviceRouteRegistry();
5992
+ const removeSseClient = (client) => removeStream(sseClients, deviceRoutes, client);
6027
5993
  /** The frame at which a bot's audience last changed (see the SSE route). */
6028
5994
  let audienceChangedAt = 0;
6029
5995
  /** A bot's audience changed: end every member stream. Each reconnects at
@@ -6035,27 +6001,15 @@ function audienceChanged() {
6035
6001
  for (const client of sseClients) {
6036
6002
  if (client.viewer.kind !== "member")
6037
6003
  continue;
6038
- sseClients.delete(client);
6039
- try {
6040
- client.res.end();
6041
- }
6042
- catch {
6043
- /* already gone */
6044
- }
6004
+ removeSseClient(client);
6045
6005
  }
6046
6006
  }
6047
6007
  function closeSessionStreams(sessionId) {
6048
6008
  browserLive.closeForOwner(sessionId);
6049
- for (const client of sseClients) {
6009
+ for (const client of Array.from(sseClients)) {
6050
6010
  if (client.sessionId !== sessionId)
6051
6011
  continue;
6052
- sseClients.delete(client);
6053
- try {
6054
- client.res.end();
6055
- }
6056
- catch {
6057
- /* already gone */
6058
- }
6012
+ removeSseClient(client);
6059
6013
  }
6060
6014
  }
6061
6015
  sessions.onSessionRevoked((sessionId) => {
@@ -6253,7 +6207,7 @@ function broadcast(payload, options = {}) {
6253
6207
  // Screen frames are replaceable and durable events are not: see
6254
6208
  // ./sse-fanout.ts for the backpressure/bound decision this makes.
6255
6209
  if (deliverSseFrame(client, kind, out) === "disconnected") {
6256
- sseClients.delete(client);
6210
+ removeSseClient(client);
6257
6211
  }
6258
6212
  }
6259
6213
  }
@@ -8271,7 +8225,7 @@ bus.subscribe((event) => {
8271
8225
  if (!event.ok && event.stopReason !== "interrupted" && !lazyClaimAlreadyReported && !routines?.runForThread(event.threadId)) {
8272
8226
  const broken = bot ?? (speaker ? store.bot(speaker.botId) : undefined);
8273
8227
  if (broken)
8274
- reportIncident({ kind: "failed", bot: broken, threadId: event.threadId, detail: failedRunDetail(event.stopReason, turnError) });
8228
+ reportIncident({ kind: "failed", bot: broken, threadId: event.threadId, detail: failedRunDetail(event.stopReason, turnError), notifyChief: event.stopReason === "auth_required" });
8275
8229
  }
8276
8230
  const lastReported = turnUsage.get(event.threadId);
8277
8231
  turnUsage.delete(event.threadId);
@@ -8637,7 +8591,7 @@ function reportIncident(input) {
8637
8591
  const task = store.taskByThread(bot.id, threadId);
8638
8592
  // A thread another bot opened and is watching is that bot's to handle:
8639
8593
  // the delegator is woken with the failure already (wakeDelegationSource).
8640
- if (task?.openedBy?.delegationId || delegationWatch.has(threadId) || roomHandoffs.activeDirect(threadId))
8594
+ if (!input.notifyChief && (task?.openedBy?.delegationId || delegationWatch.has(threadId) || roomHandoffs.activeDirect(threadId)))
8641
8595
  return;
8642
8596
  const group = store.groupByThread(threadId);
8643
8597
  const incident = {
@@ -10760,6 +10714,7 @@ async function startTurn(botId, text, opts) {
10760
10714
  ? await nexusMemory.readBack({ bot: liveBot ?? bot, threadId, query: providerText })
10761
10715
  : "";
10762
10716
  const prompt = buildSystemPrompt(persona, liveBot?.soul ?? bot.soul ?? "", roomPromptSections(roomTurn, [
10717
+ { id: "personal-goal", label: "Active goal", text: personalGoals.goals.get(threadId)?.status === "working" ? personalGoalInstructions(personalGoals.goals.get(threadId)) : "" },
10763
10718
  { id: "user-profile", label: "About the user", text: userProfileSystemPrompt(cfg.profile) },
10764
10719
  // the ICM folder is on this machine; a cloud run cannot reach it
10765
10720
  // first after the soul: the block names agent tools, so it only goes
@@ -16129,7 +16084,7 @@ ROUTES.push(createSetupRoutes({
16129
16084
  }
16130
16085
  },
16131
16086
  startOllama: async (context) => {
16132
- await runTool("/usr/bin/open", ["-a", "Ollama"]);
16087
+ startOllamaApp();
16133
16088
  for (let attempt = 0; attempt < 30 && !context.signal.aborted; attempt++) {
16134
16089
  if ((await setupOllamaState()) === "running")
16135
16090
  return;
@@ -19062,6 +19017,7 @@ const handleRequest = async (req, res) => {
19062
19017
  admin: auth.scopes.includes("admin"),
19063
19018
  screens: url.searchParams.get("screens") !== "off",
19064
19019
  backpressured: false,
19020
+ authKind: auth.kind,
19065
19021
  viewer,
19066
19022
  // What this person's hydration (GET /api/bots, narrowed the same way) showed them.
19067
19023
  seen: viewer.kind === "all" && viewer.privateOwner ? { bots: new Set(), groups: new Set() } : {
@@ -19105,6 +19061,7 @@ const handleRequest = async (req, res) => {
19105
19061
  // A client that offered no cursor gets false too, which is exactly
19106
19062
  // what a cold start should do.
19107
19063
  resumed,
19064
+ approvalTarget: deviceRoutes.route(),
19108
19065
  })}\n\n`);
19109
19066
  if (resumed) {
19110
19067
  for (const buffered of replayBuffer) {
@@ -19120,6 +19077,7 @@ const handleRequest = async (req, res) => {
19120
19077
  }
19121
19078
  }
19122
19079
  sseClients.add(client);
19080
+ deviceRoutes.streamOpened(client.authKind);
19123
19081
  // Keep this long-lived response out of socket idle-timeout handling
19124
19082
  // without weakening timeouts for every other API request.
19125
19083
  req.socket.setTimeout(0);
@@ -19139,7 +19097,7 @@ const handleRequest = async (req, res) => {
19139
19097
  }, SSE_HEARTBEAT_MS);
19140
19098
  req.on("close", () => {
19141
19099
  clearInterval(keepalive);
19142
- sseClients.delete(client);
19100
+ removeSseClient(client);
19143
19101
  });
19144
19102
  return;
19145
19103
  }
@@ -20584,11 +20542,11 @@ const handleRequest = async (req, res) => {
20584
20542
  });
20585
20543
  }
20586
20544
  if (method === "POST" && path === "/api/bot-defaults/skills/preview") {
20587
- const checked = z.object({ source: z.string().min(1).max(2000) }).strict().safeParse(await readBody(req));
20545
+ // Drafts accept the same uploaded bytes as existing bots, without installing them.
20546
+ const checked = skillImportSchema.safeParse(await readBody(req));
20588
20547
  if (!checked.success)
20589
20548
  return json(res, 400, { error: checked.error.message });
20590
- const input = checked.data;
20591
- const fetched = await fetchSkillFromSource(input.source);
20549
+ const fetched = await resolveSkillImport(checked.data);
20592
20550
  if ("error" in fetched)
20593
20551
  return json(res, 422, { error: fetched.error });
20594
20552
  const skills = [];
@@ -21701,10 +21659,10 @@ const handleRequest = async (req, res) => {
21701
21659
  if (m && method === "POST") {
21702
21660
  if (!store.bot(m[1]))
21703
21661
  return json(res, 404, { error: "no such bot" });
21704
- const parsed = z.object({ source: z.string().min(1).max(2000) }).safeParse(await readBody(req));
21662
+ const parsed = skillImportSchema.safeParse(await readBody(req));
21705
21663
  if (!parsed.success)
21706
- return json(res, 400, { error: "source must be a GitHub URL or owner/repo" });
21707
- const fetched = await fetchSkillFromSource(parsed.data.source);
21664
+ return json(res, 400, { error: "Choose a Markdown skill file or enter a GitHub source (maximum 256 KiB per file)." });
21665
+ const fetched = await resolveSkillImport(parsed.data);
21708
21666
  if ("error" in fetched)
21709
21667
  return json(res, 422, { error: fetched.error });
21710
21668
  const results = fetched.skills.map((skill) => installSkill(m[1], skill.source, skill.files));
@@ -22233,10 +22191,20 @@ const handleRequest = async (req, res) => {
22233
22191
  userAgent: typeof req.headers["user-agent"] === "string" ? req.headers["user-agent"] : undefined,
22234
22192
  relayed: Boolean(onBehalfOf),
22235
22193
  }));
22194
+ let goalCommand;
22195
+ try {
22196
+ goalCommand = parseGoalCommand(text);
22197
+ }
22198
+ catch (error) {
22199
+ return json(res, 400, { error: error instanceof Error ? error.message : "Invalid goal command" });
22200
+ }
22201
+ if (guarded && goalCommand)
22202
+ return json(res, 400, { error: "Goal commands require the conversation command interface." });
22236
22203
  // The send is acknowledged before the turn starts, so a workspace at its
22237
22204
  // spend limit is refused here, where the person can see it.
22238
22205
  try {
22239
- assertWithinBudget(cfg, DATA_DIR);
22206
+ if (!goalCommand || goalCommand.action === "start" || goalCommand.action === "resume")
22207
+ assertWithinBudget(cfg, DATA_DIR);
22240
22208
  }
22241
22209
  catch (error) {
22242
22210
  return json(res, 409, { error: error instanceof Error ? error.message : String(error), code: "spend_cap" });
@@ -22305,6 +22273,12 @@ const handleRequest = async (req, res) => {
22305
22273
  const message = await startTurn(bot.id, text, { threadId, replyTo, sendId, sender: messageSender(auth), trigger });
22306
22274
  return { ok: true, threadId, message };
22307
22275
  }
22276
+ if (goalCommand) {
22277
+ if (auth.kind === "assistant")
22278
+ throw Object.assign(new Error("Only the person controls goals."), { status: 403 });
22279
+ return receiveGoalCommand(personalGoals, store, bot.id, threadId, goalCommand, text, sendId, messageSender(auth), trigger);
22280
+ }
22281
+ personalGoals.steer(threadId);
22308
22282
  // Claude can accept the message inside its live turn. If the write
22309
22283
  // loses a race with turn settlement, or the engine cannot steer, the
22310
22284
  // existing server-side queue records it atomically for the next turn.
@@ -23733,6 +23707,8 @@ const handleRequest = async (req, res) => {
23733
23707
  if (method === "GET" && authStatus) {
23734
23708
  res.setHeader("cache-control", "no-store");
23735
23709
  const state = await providerAuthSessions.status(authStatus[1], auth.kind === "session" ? auth.session.id : "loopback", url.searchParams.get("flowId") ?? "");
23710
+ if (state.phase === "succeeded")
23711
+ engineRests.clear(authStatus[1], "signed-in");
23736
23712
  return json(res, 200, { auth: state });
23737
23713
  }
23738
23714
  const instanceAction = /^\/api\/instances\/([\w.-]+)\/(refresh-models|install|auth\/start|auth\/complete|auth\/cancel|auth\/sign-out)$/.exec(path);
@@ -23774,6 +23750,8 @@ const handleRequest = async (req, res) => {
23774
23750
  providerAuthSessions.revokeOwner(owner);
23775
23751
  return json(res, 401, { error: "Your session ended. Start a new sign-in." });
23776
23752
  }
23753
+ if (started.phase === "succeeded")
23754
+ engineRests.clear(instanceId, "signed-in");
23777
23755
  return json(res, 200, { auth: started });
23778
23756
  }
23779
23757
  if (action === "auth/sign-out") {
@@ -23791,6 +23769,8 @@ const handleRequest = async (req, res) => {
23791
23769
  if (!flowId || !callbackUrl)
23792
23770
  return json(res, 400, { error: "flowId and a code or callbackUrl are required" });
23793
23771
  await providerAuthSessions.complete(instanceId, owner, flowId, callbackUrl);
23772
+ engineRests.clear(instanceId, "signed-in");
23773
+ await describeEngines();
23794
23774
  return json(res, 200, { ok: true });
23795
23775
  }
23796
23776
  const body = await readBody(req, 4096);
@@ -0,0 +1,86 @@
1
+ import { GOAL_HELP } from "../shared/personal-goal.js";
2
+ import { PersonalGoals } from "./personal-goals.js";
3
+ /** Serial continuation per goal; every provider turn still passes normal admission. */
4
+ export class PersonalGoalRuntime {
5
+ goals;
6
+ running = new Set();
7
+ scheduled = new Map();
8
+ host;
9
+ constructor(path, host) {
10
+ this.host = host;
11
+ this.goals = new PersonalGoals(path, (goal) => host.changed(goal));
12
+ }
13
+ async command(botId, threadId, command, trigger) {
14
+ if (command.action === "help")
15
+ return GOAL_HELP;
16
+ if (this.goals.loadError)
17
+ return this.goals.loadError;
18
+ let goal = this.goals.get(threadId);
19
+ if (command.action === "status")
20
+ return goal ? this.describe(goal) : "This conversation has no goal. " + GOAL_HELP;
21
+ if (command.action === "start" || command.action === "resume") {
22
+ if (this.host.busy(botId, threadId) || (goal && this.running.has(goal.id)))
23
+ throw new Error("This conversation is still working. Wait for its turn to settle.");
24
+ goal = command.action === "start" ? this.goals.start(botId, threadId, { ...command, ...(trigger?.kind === "user" || trigger?.kind === "owner" ? { trigger } : {}) }) : this.goals.resume(threadId, command);
25
+ // Persist the human command before provider setup reads the conversation.
26
+ this.running.add(goal.id);
27
+ this.scheduled.set(goal.id, setImmediate(() => { this.scheduled.delete(goal.id); void this.run(goal); }));
28
+ }
29
+ else {
30
+ goal = this.goals.control(threadId, command.action);
31
+ const pending = this.scheduled.get(goal.id);
32
+ if (pending) {
33
+ clearImmediate(pending);
34
+ this.scheduled.delete(goal.id);
35
+ this.running.delete(goal.id);
36
+ }
37
+ if (this.running.has(goal.id))
38
+ await this.host.interrupt(botId, threadId, goal.id);
39
+ }
40
+ return this.describe(goal);
41
+ }
42
+ describe(goal) {
43
+ return `${goal.status}: ${goal.objective}\n${goal.detail}\nTurns: ${goal.turnCount}/${goal.maxTurns}. Tokens: ${goal.tokensUsed}${goal.tokenBudget === undefined ? "" : `/${goal.tokenBudget}`}.`;
44
+ }
45
+ steer(threadId) {
46
+ const goal = this.goals.get(threadId);
47
+ if (goal?.status === "working") {
48
+ goal.status = "paused";
49
+ goal.detail = "You sent a message. Use /goal resume when ready to continue.";
50
+ this.goals.touch(goal);
51
+ }
52
+ }
53
+ async run(goal) {
54
+ this.running.add(goal.id);
55
+ try {
56
+ while (this.goals.begin(goal.id)) {
57
+ const result = await this.host.run(goal);
58
+ this.goals.settle(goal.id, result);
59
+ // Give queued human input priority before reserving another turn.
60
+ await new Promise((resolve) => setTimeout(resolve, 0));
61
+ }
62
+ }
63
+ catch {
64
+ if (goal.status === "working") {
65
+ goal.status = "paused";
66
+ goal.detail = "The next turn could not start. Check the conversation and use /goal resume.";
67
+ this.goals.touch(goal);
68
+ }
69
+ }
70
+ finally {
71
+ this.running.delete(goal.id);
72
+ }
73
+ }
74
+ }
75
+ export async function receiveGoalCommand(runtime, store, botId, threadId, command, text, sendId, sender, trigger) {
76
+ let result;
77
+ try {
78
+ result = await runtime.command(botId, threadId, command, trigger);
79
+ }
80
+ catch (error) {
81
+ throw Object.assign(error instanceof Error ? error : new Error(String(error)), { status: 409 });
82
+ }
83
+ const message = store.appendMessage(threadId, { role: "user", kind: "text", text, sendId, sender });
84
+ store.appendMessage(threadId, { role: "bot", kind: "text", text: result });
85
+ return { ok: true, threadId, message, goalSummary: result };
86
+ }
@@ -0,0 +1,125 @@
1
+ export function updatePersonalGoalReceipt(goal, store) {
2
+ if (!store.taskByThread(goal.botId, goal.threadId))
3
+ return;
4
+ const goalRun = { runId: goal.id, goal: goal.objective, status: goal.status,
5
+ coordinatorBotId: goal.botId, coordinatorName: store.bot(goal.botId)?.name ?? "Bot",
6
+ turnCount: goal.turnCount, maxTurns: goal.maxTurns, detail: `${goal.detail} Tokens: ${goal.tokensUsed}${goal.tokenBudget === undefined ? "" : `/${goal.tokenBudget}`}.`, startedAt: goal.createdAt };
7
+ if (goal.receiptMessageId)
8
+ store.patchMessage(goal.threadId, goal.receiptMessageId, { goalRun });
9
+ else
10
+ goal.receiptMessageId = store.appendMessage(goal.threadId, { role: "bot", kind: "goal.run", goalRun }).id;
11
+ }
12
+ export async function runPersonalGoalTurn(goal, host) {
13
+ const guard = { token: Symbol("personal-goal"), personalGoalId: goal.id, assistantItems: [], discard: false };
14
+ let ok = false, tools = 0, measured = false, completed = false;
15
+ let tokens;
16
+ const before = host.store.taskByThread(goal.botId, goal.threadId)?.usage;
17
+ const beforeTokens = (before?.input ?? 0) + (before?.output ?? 0);
18
+ host.addGuard(goal.threadId, guard);
19
+ const unsub = host.subscribe((event) => {
20
+ if (event.threadId !== goal.threadId || host.ignore(event))
21
+ return;
22
+ if (guard.turnId && event.turnId && guard.turnId !== event.turnId)
23
+ return;
24
+ if (event.type === "item.completed" && event.itemType === "tool")
25
+ tools++;
26
+ if (event.type === "thread.token-usage.updated")
27
+ measured = true;
28
+ if (event.type === "turn.completed") {
29
+ completed = true;
30
+ ok = event.ok;
31
+ measured ||= event.usage !== undefined;
32
+ const usage = host.store.taskByThread(goal.botId, goal.threadId)?.usage;
33
+ if (measured)
34
+ tokens = Math.max(0, (usage?.input ?? 0) + (usage?.output ?? 0) - beforeTokens);
35
+ }
36
+ });
37
+ try {
38
+ await new Promise((resolve, reject) => {
39
+ void host.start(goal.botId, goal.turnCount === 1 ? goal.objective : "Continue the active goal.", {
40
+ threadId: goal.threadId, cardContinuation: true, trigger: goal.trigger,
41
+ onTurnSettled: () => setImmediate(resolve), onDispatchError: reject,
42
+ }).catch(reject);
43
+ });
44
+ // The terminal event precedes digest/screen resource release. Never overlap those claims.
45
+ const deadline = Date.now() + 30_000;
46
+ while (host.busy(goal.botId, goal.threadId) && Date.now() < deadline)
47
+ await new Promise(resolve => setTimeout(resolve, 25));
48
+ if (host.busy(goal.botId, goal.threadId))
49
+ throw new Error("Turn is still settling");
50
+ return { ok, text: guard.assistantItems.join("\n"), tools, tokens };
51
+ }
52
+ finally {
53
+ unsub();
54
+ // Before a provider id exists, direct-turn admission owns late setup events.
55
+ // Keeping an unbound guard here would swallow an unrelated later turn.
56
+ if (completed || !guard.turnId)
57
+ host.removeGuard(goal.threadId, guard);
58
+ else {
59
+ guard.discard = true;
60
+ guard.cleanupTimer = setTimeout(() => host.removeGuard(goal.threadId, guard), 5 * 60_000);
61
+ guard.cleanupTimer.unref();
62
+ }
63
+ }
64
+ }
65
+ export function addGoalGuard(groupGoalCoordinatorTurns, threadId, turn) {
66
+ const turns = groupGoalCoordinatorTurns.get(threadId) ?? new Set();
67
+ turns.add(turn);
68
+ groupGoalCoordinatorTurns.set(threadId, turns);
69
+ }
70
+ export function removeGoalGuard(groupGoalCoordinatorTurns, threadId, turn) {
71
+ if (turn.cleanupTimer)
72
+ clearTimeout(turn.cleanupTimer);
73
+ const turns = groupGoalCoordinatorTurns.get(threadId);
74
+ turns?.delete(turn);
75
+ if (turns?.size === 0)
76
+ groupGoalCoordinatorTurns.delete(threadId);
77
+ }
78
+ export function hasUnboundGoalGuard(groupGoalCoordinatorTurns, threadId) {
79
+ return [...(groupGoalCoordinatorTurns.get(threadId) ?? [])]
80
+ .some((turn) => turn.discard && !turn.turnId);
81
+ }
82
+ /** Match private coordinator output to one provider turn, never merely to a
83
+ * reusable room thread. Most adapters emit turn.started before sendTurn
84
+ * resolves, so the first stable event may bind an otherwise pending guard. */
85
+ export function goalGuardForEvent(groupGoalCoordinatorTurns, retireProviderTurn, event) {
86
+ const turns = groupGoalCoordinatorTurns.get(event.threadId);
87
+ if (!turns?.size)
88
+ return undefined;
89
+ const candidates = [...turns];
90
+ if (event.turnId) {
91
+ const exact = candidates.find((turn) => turn.turnId === event.turnId);
92
+ if (exact)
93
+ return exact;
94
+ // Until an interrupted handshake returns its own id, no new id can be
95
+ // attributed safely. The stall fallback keeps this thread unavailable in
96
+ // that narrow window; private text is suppressed below until sendTurn's
97
+ // result binds the old guard or its bounded expiry releases ownership.
98
+ const unboundDiscarded = candidates.filter((turn) => turn.discard && !turn.turnId);
99
+ if (unboundDiscarded.length > 0) {
100
+ // The ownership fallback below keeps a lone abandoned handshake's
101
+ // thread closed, so its first eventual id can safely bind here. More
102
+ // than one unbound candidate is genuinely ambiguous and stays gated.
103
+ if (candidates.length === 1) {
104
+ unboundDiscarded[0].turnId = event.turnId;
105
+ // This event is the first stable identity for an already-abandoned
106
+ // provider turn. Tombstone it immediately so this event and every
107
+ // later completion/request cannot settle a replacement on the same
108
+ // room thread.
109
+ retireProviderTurn(event.turnId);
110
+ return unboundDiscarded[0];
111
+ }
112
+ return undefined;
113
+ }
114
+ const pending = candidates.findLast((turn) => !turn.turnId && !turn.discard);
115
+ if (pending && !pending.turnId) {
116
+ pending.turnId = event.turnId;
117
+ return pending;
118
+ }
119
+ return undefined;
120
+ }
121
+ // Turn-scoped events normally carry an id. If an adapter omits it, fail
122
+ // closed for private text; with multiple overlapping guards there is no
123
+ // safe way to attribute a completion, so leave cleanup to the bounded timer.
124
+ return candidates.length === 1 ? candidates[0] : undefined;
125
+ }