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
@@ -12,6 +12,9 @@ function routineRequestOwnerKey(owner) {
12
12
  const ALL_DAYS = [0, 1, 2, 3, 4, 5, 6];
13
13
  const CATCH_UP_MS = 12 * 60 * 60_000;
14
14
  const MAX_DATE_MS = 8_640_000_000_000_000;
15
+ const LOCAL_DAY_MS = 24 * 60 * 60_000;
16
+ const INTERVAL_RESTRICTION_SEARCH_MS = 9 * LOCAL_DAY_MS;
17
+ const CLOCK_TIME = /^([01]\d|2[0-3]):([0-5]\d)$/;
15
18
  const MAX_RUNS = 2_000;
16
19
  const MAX_ATTACHMENTS = 50;
17
20
  const attachmentSchema = z.object({
@@ -86,7 +89,14 @@ function cloneSchedule(schedule) {
86
89
  if (schedule.type === "once")
87
90
  return { type: "once", at: schedule.at };
88
91
  if (schedule.type === "interval") {
89
- return { type: "interval", everyMinutes: schedule.everyMinutes, anchorAt: schedule.anchorAt };
92
+ return {
93
+ type: "interval",
94
+ everyMinutes: schedule.everyMinutes,
95
+ anchorAt: schedule.anchorAt,
96
+ ...(schedule.weekdays ? { weekdays: [...schedule.weekdays] } : {}),
97
+ ...(schedule.window ? { window: { ...schedule.window } } : {}),
98
+ ...(schedule.endsAt === undefined ? {} : { endsAt: schedule.endsAt }),
99
+ };
90
100
  }
91
101
  return { type: "daily", time: schedule.time, weekdays: [...schedule.weekdays] };
92
102
  }
@@ -164,6 +174,57 @@ function composeExecutionPrompt(prompt, attachments, carry) {
164
174
  }
165
175
  return parts.filter(Boolean).join("\n\n");
166
176
  }
177
+ function cleanIntervalWeekdays(value) {
178
+ if (value == null)
179
+ return undefined;
180
+ if (!Array.isArray(value) || value.length === 0 || value.length > ALL_DAYS.length) {
181
+ throw new Error("Choose at least one valid interval day");
182
+ }
183
+ if (value.some((day) => typeof day !== "number" || !Number.isInteger(day) || day < 0 || day > 6)) {
184
+ throw new Error("Interval days must be whole numbers from 0 to 6");
185
+ }
186
+ if (new Set(value).size !== value.length)
187
+ throw new Error("Choose each interval day only once");
188
+ const weekdays = [...value].sort((a, b) => a - b);
189
+ return weekdays.length === ALL_DAYS.length ? undefined : weekdays;
190
+ }
191
+ function clockMinutes(value) {
192
+ const match = CLOCK_TIME.exec(value);
193
+ return match ? Number(match[1]) * 60 + Number(match[2]) : null;
194
+ }
195
+ function cleanIntervalWindow(value, everyMinutes) {
196
+ if (value == null)
197
+ return undefined;
198
+ if (typeof value !== "object" || Array.isArray(value)) {
199
+ throw new Error("Choose a valid interval time window");
200
+ }
201
+ const candidate = value;
202
+ const start = typeof candidate.start === "string" ? candidate.start : "";
203
+ const end = typeof candidate.end === "string" ? candidate.end : "";
204
+ const startMinutes = clockMinutes(start);
205
+ const endMinutes = clockMinutes(end);
206
+ if (startMinutes == null || endMinutes == null) {
207
+ throw new Error("Interval window times must use HH:MM");
208
+ }
209
+ if (startMinutes >= endMinutes) {
210
+ throw new Error("Interval window must start before it ends on the same day");
211
+ }
212
+ if (endMinutes - startMinutes < everyMinutes) {
213
+ throw new Error("Interval window must be at least as long as the interval");
214
+ }
215
+ return { start, end };
216
+ }
217
+ function cleanIntervalEndsAt(value, anchorAt) {
218
+ if (value == null)
219
+ return undefined;
220
+ if (typeof value !== "number" ||
221
+ !Number.isSafeInteger(value) ||
222
+ value < anchorAt ||
223
+ value > MAX_DATE_MS) {
224
+ throw new Error("Choose a valid interval end time after its start");
225
+ }
226
+ return value;
227
+ }
167
228
  function finishedOrder(run) {
168
229
  return run.finishedAt ?? run.createdAt;
169
230
  }
@@ -191,7 +252,17 @@ function cleanSchedule(schedule) {
191
252
  anchorAt > MAX_DATE_MS) {
192
253
  throw new Error("Choose a valid interval start time");
193
254
  }
194
- return { type: "interval", everyMinutes, anchorAt };
255
+ const weekdays = cleanIntervalWeekdays(schedule.weekdays);
256
+ const window = cleanIntervalWindow(schedule.window, everyMinutes);
257
+ const endsAt = cleanIntervalEndsAt(schedule.endsAt, anchorAt);
258
+ return {
259
+ type: "interval",
260
+ everyMinutes,
261
+ anchorAt,
262
+ ...(weekdays ? { weekdays } : {}),
263
+ ...(window ? { window } : {}),
264
+ ...(endsAt === undefined ? {} : { endsAt }),
265
+ };
195
266
  }
196
267
  throw new Error("Choose a supported schedule");
197
268
  }
@@ -203,17 +274,56 @@ function loadSchedule(value) {
203
274
  return null;
204
275
  }
205
276
  }
277
+ function intervalHasRestrictions(schedule) {
278
+ return schedule.weekdays !== undefined || schedule.window !== undefined || schedule.endsAt !== undefined;
279
+ }
280
+ function intervalAllowsOccurrence(schedule, at) {
281
+ if (schedule.endsAt !== undefined && at > schedule.endsAt)
282
+ return false;
283
+ const date = new Date(at);
284
+ if (schedule.weekdays && !schedule.weekdays.includes(date.getDay()))
285
+ return false;
286
+ if (schedule.window) {
287
+ const minute = date.getHours() * 60 + date.getMinutes();
288
+ const start = clockMinutes(schedule.window.start);
289
+ const end = clockMinutes(schedule.window.end);
290
+ if (minute < start || minute >= end)
291
+ return false;
292
+ }
293
+ return true;
294
+ }
295
+ function isSameLocalDay(left, right) {
296
+ const a = new Date(left);
297
+ const b = new Date(right);
298
+ return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
299
+ }
300
+ function nextAlignedInterval(schedule, after) {
301
+ if (schedule.anchorAt > after)
302
+ return schedule.anchorAt;
303
+ const intervalMs = schedule.everyMinutes * 60_000;
304
+ const intervalsElapsed = Math.floor((after - schedule.anchorAt) / intervalMs);
305
+ const candidate = schedule.anchorAt + (intervalsElapsed + 1) * intervalMs;
306
+ return Number.isSafeInteger(candidate) && candidate <= MAX_DATE_MS ? candidate : null;
307
+ }
206
308
  /** Next wall-clock occurrence in this computer's timezone, strictly after `after`. */
207
309
  export function nextOccurrence(schedule, after) {
208
310
  if (schedule.type === "once")
209
311
  return schedule.at > after ? schedule.at : null;
210
312
  if (schedule.type === "interval") {
211
- if (schedule.anchorAt > after)
212
- return schedule.anchorAt;
213
313
  const intervalMs = schedule.everyMinutes * 60_000;
214
- const intervalsElapsed = Math.floor((after - schedule.anchorAt) / intervalMs);
215
- const candidate = schedule.anchorAt + (intervalsElapsed + 1) * intervalMs;
216
- return Number.isSafeInteger(candidate) && candidate <= MAX_DATE_MS ? candidate : null;
314
+ let candidate = nextAlignedInterval(schedule, after);
315
+ if (!intervalHasRestrictions(schedule))
316
+ return candidate;
317
+ const maxCandidates = Math.ceil(INTERVAL_RESTRICTION_SEARCH_MS / intervalMs) + 2;
318
+ for (let checked = 0; candidate !== null && checked < maxCandidates; checked++) {
319
+ if (schedule.endsAt !== undefined && candidate > schedule.endsAt)
320
+ return null;
321
+ if (intervalAllowsOccurrence(schedule, candidate))
322
+ return candidate;
323
+ const next = candidate + intervalMs;
324
+ candidate = Number.isSafeInteger(next) && next <= MAX_DATE_MS ? next : null;
325
+ }
326
+ return null;
217
327
  }
218
328
  const [hour, minute] = schedule.time.split(":").map(Number);
219
329
  const weekdays = new Set(cleanDays(schedule.weekdays));
@@ -227,10 +337,38 @@ export function nextOccurrence(schedule, after) {
227
337
  return null;
228
338
  }
229
339
  function latestIntervalOccurrence(schedule, at) {
230
- if (schedule.anchorAt > at)
231
- return null;
232
340
  const intervalMs = schedule.everyMinutes * 60_000;
233
- return schedule.anchorAt + Math.floor((at - schedule.anchorAt) / intervalMs) * intervalMs;
341
+ const ceiling = Math.min(at, schedule.endsAt ?? at);
342
+ if (schedule.anchorAt > ceiling)
343
+ return null;
344
+ let candidate = schedule.anchorAt + Math.floor((ceiling - schedule.anchorAt) / intervalMs) * intervalMs;
345
+ if (!intervalHasRestrictions(schedule))
346
+ return candidate;
347
+ const maxCandidates = Math.ceil(INTERVAL_RESTRICTION_SEARCH_MS / intervalMs) + 2;
348
+ for (let checked = 0; checked < maxCandidates; checked++) {
349
+ if (intervalAllowsOccurrence(schedule, candidate))
350
+ return candidate;
351
+ const previous = candidate - intervalMs;
352
+ if (!Number.isSafeInteger(previous) || previous < schedule.anchorAt)
353
+ return null;
354
+ candidate = previous;
355
+ }
356
+ return null;
357
+ }
358
+ function mergeScheduleUpdate(current, incoming) {
359
+ if (current.type !== "interval" || incoming.type !== "interval")
360
+ return incoming;
361
+ const merged = { ...incoming };
362
+ if (!Object.hasOwn(incoming, "weekdays") && current.weekdays) {
363
+ merged.weekdays = [...current.weekdays];
364
+ }
365
+ if (!Object.hasOwn(incoming, "window") && current.window) {
366
+ merged.window = { ...current.window };
367
+ }
368
+ if (!Object.hasOwn(incoming, "endsAt") && current.endsAt !== undefined) {
369
+ merged.endsAt = current.endsAt;
370
+ }
371
+ return merged;
234
372
  }
235
373
  function sanitizeInput(input) {
236
374
  const name = String(input.name ?? "").trim().slice(0, 80);
@@ -311,6 +449,7 @@ export class RoutineManager {
311
449
  timeoutMinutes: loadTimeoutMinutes(routine.timeoutMinutes),
312
450
  attachments: loadAttachments(routine.attachments),
313
451
  sourceThreadId: persistedSourceThreadId.parse(routine.sourceThreadId),
452
+ resultsThreadId: persistedSourceThreadId.parse(routine.resultsThreadId),
314
453
  };
315
454
  if (loaded.timeoutMinutes === undefined)
316
455
  delete loaded.timeoutMinutes;
@@ -329,6 +468,7 @@ export class RoutineManager {
329
468
  timeoutMinutes: loadTimeoutMinutes(run.timeoutMinutes),
330
469
  attachments: loadAttachments(run.attachments),
331
470
  sourceThreadId: persistedSourceThreadId.parse(run.sourceThreadId),
471
+ resultsThreadId: persistedSourceThreadId.parse(run.resultsThreadId),
332
472
  };
333
473
  if (loaded.timeoutMinutes === undefined)
334
474
  delete loaded.timeoutMinutes;
@@ -467,21 +607,26 @@ export class RoutineManager {
467
607
  if (this.targetState(clean) === "missing")
468
608
  throw new Error(this.missingTargetMessage(clean.target));
469
609
  const at = this.now();
610
+ const nextRunAt = clean.enabled ? this.initialOccurrence(clean.schedule, at) : null;
611
+ if (clean.schedule.type === "interval" && clean.enabled && nextRunAt === null) {
612
+ throw new Error("This interval has no future runs. Choose a later end date or turn it off.");
613
+ }
470
614
  const routine = {
471
615
  id: randomUUID(),
472
616
  ...clean,
473
617
  // Only a confirmed chat card supplies `request`; the public calendar
474
618
  // API cannot choose an arbitrary transcript as a reporting target.
475
619
  sourceThreadId: request?.threadId,
476
- nextRunAt: clean.enabled ? this.initialOccurrence(clean.schedule, at) : null,
620
+ nextRunAt,
477
621
  createdAt: at,
478
622
  updatedAt: at,
479
623
  };
624
+ const discardResults = this.applyResultsInput(routine, input.resultsThreadId);
480
625
  this.commitMutation(() => {
481
626
  this.routines.unshift(routine);
482
627
  if (request)
483
628
  this.rememberRoutineRequest(request, routine.id, at);
484
- });
629
+ }, discardResults);
485
630
  this.emitRoutine(routine);
486
631
  return cloneRoutine(routine);
487
632
  }
@@ -505,7 +650,7 @@ export class RoutineManager {
505
650
  groupId: Object.hasOwn(patch, "groupId") ? patch.groupId : routine.groupId,
506
651
  runOn: patch.runOn ?? routine.runOn,
507
652
  enabled: patch.enabled ?? routine.enabled,
508
- schedule: patch.schedule ?? routine.schedule,
653
+ schedule: patch.schedule ? mergeScheduleUpdate(routine.schedule, patch.schedule) : routine.schedule,
509
654
  durationMinutes: patch.durationMinutes ?? routine.durationMinutes,
510
655
  timeoutMinutes: Object.hasOwn(patch, "timeoutMinutes") ? patch.timeoutMinutes : routine.timeoutMinutes,
511
656
  attachments: patch.attachments ?? routine.attachments,
@@ -513,16 +658,24 @@ export class RoutineManager {
513
658
  });
514
659
  if (this.targetState(clean) === "missing")
515
660
  throw new Error(this.missingTargetMessage(clean.target));
516
- const cancelledRuns = [];
517
661
  const scheduleChanged = JSON.stringify(clean.schedule) !== JSON.stringify(routine.schedule);
518
662
  const enabledChanged = clean.enabled !== routine.enabled;
663
+ // Definition-only edits retain due work and offline catch-up.
664
+ const nextRunAt = !clean.enabled ? null : scheduleChanged || enabledChanged
665
+ ? this.initialOccurrence(clean.schedule, now)
666
+ : routine.nextRunAt;
667
+ if (clean.schedule.type === "interval" && clean.enabled && nextRunAt === null) {
668
+ throw new Error("This interval has no future runs. Choose a later end date or turn it off.");
669
+ }
670
+ const destination = { ...routine, ...clean };
671
+ if (destination.botId !== routine.botId)
672
+ delete destination.resultsThreadId;
673
+ const discardResults = this.applyResultsInput(destination, patch.resultsThreadId);
674
+ const cancelledRuns = [];
519
675
  this.commitMutation(() => {
520
676
  Object.assign(routine, clean, {
521
- // Renaming/editing instructions must not skip an occurrence that
522
- // became due since the last tick (or erase an offline catch-up).
523
- nextRunAt: !clean.enabled ? null : scheduleChanged || enabledChanged
524
- ? this.initialOccurrence(clean.schedule, now)
525
- : routine.nextRunAt,
677
+ resultsThreadId: destination.resultsThreadId,
678
+ nextRunAt,
526
679
  // `updatedAt` doubles as the optimistic revision on durable routine
527
680
  // confirmation cards. Keep it monotonic even for two writes in one ms.
528
681
  updatedAt: Math.max(now, routine.updatedAt + 1),
@@ -547,7 +700,7 @@ export class RoutineManager {
547
700
  }
548
701
  if (request)
549
702
  this.rememberRoutineRequest(request, routine.id, now);
550
- });
703
+ }, discardResults);
551
704
  for (const run of cancelledRuns)
552
705
  this.emitRun(run);
553
706
  this.emitRoutine(routine);
@@ -658,15 +811,19 @@ export class RoutineManager {
658
811
  if (!routine)
659
812
  return null;
660
813
  let run;
814
+ const allocations = [];
815
+ const previousDestination = routine.resultsThreadId;
661
816
  this.commitMutation(() => {
662
- run = this.newRun(routine, this.now(), true);
663
- // A chat-confirmed "run now" reports back to the conversation that
664
- // 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.
665
820
  if (request)
666
821
  run.sourceThreadId = request.threadId;
667
822
  if (request)
668
823
  this.rememberRoutineRequest(request, run.id, this.now());
669
- });
824
+ }, () => this.discardResultsThreads(allocations));
825
+ if (routine.resultsThreadId !== previousDestination)
826
+ this.emitRoutine(routine);
670
827
  this.emitRun(run);
671
828
  queueMicrotask(() => void this.tick());
672
829
  return cloneRun(run);
@@ -833,51 +990,57 @@ export class RoutineManager {
833
990
  await this.options.interruptTurn?.(run.botId, threadId, run.runOn ?? "maus").catch(() => { });
834
991
  }
835
992
  }
836
- let changed = false;
837
- const missedRuns = [];
838
- for (const routine of this.routines) {
839
- if (!routine.enabled || routine.nextRunAt == null || routine.nextRunAt > now)
840
- continue;
841
- const pendingAt = routine.nextRunAt;
842
- const late = now - pendingAt;
843
- const scheduledFor = routine.schedule.type === "interval" && late <= CATCH_UP_MS
844
- ? latestIntervalOccurrence(routine.schedule, now) ?? pendingAt
845
- : pendingAt;
846
- // One slow interval run must not build an unbounded queue of stale
847
- // copies behind it. The series still advances on its original phase.
848
- const overlapping = routine.schedule.type === "interval" && this.runs.some((run) => run.routineId === routine.id && ["queued", "running", "waiting"].includes(run.status));
849
- if (!overlapping) {
850
- if (late > CATCH_UP_MS) {
851
- const missed = this.newRun(routine, scheduledFor, false);
852
- missed.status = "missed";
853
- missed.finishedAt = now;
854
- missed.error = "This computer was offline for more than 12 hours after the scheduled time";
855
- this.emitRun(missed);
856
- missedRuns.push(cloneRun(missed));
857
- }
858
- else {
859
- const run = this.newRun(routine, scheduledFor, false);
860
- 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
+ }
861
1031
  }
862
- }
863
- routine.nextRunAt =
864
- routine.schedule.type === "once" ? null : nextOccurrence(routine.schedule, Math.max(now, scheduledFor));
865
- // `updatedAt` is the optimistic definition revision carried by
866
- // routine confirmation cards. Moving the scheduler cursor is runtime
867
- // progress, not a definition edit, so recurring ticks must not make a
868
- // still-accurate pending confirmation stale. A one-time routine does
869
- // mutate its definition by auto-disabling after its occurrence.
870
- if (routine.schedule.type === "once") {
871
- routine.enabled = false;
872
- routine.updatedAt = Math.max(now, routine.updatedAt + 1);
873
- }
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)
874
1037
  this.emitRoutine(routine);
875
- 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));
876
1043
  }
877
- if (changed)
878
- this.save();
879
- for (const missed of missedRuns)
880
- this.options.onRunFailed?.(missed);
881
1044
  // Oldest queued requests have priority. New manual/webhook arrivals
882
1045
  // must not continually overtake work that has already waited. Snapshot
883
1046
  // the queue because each dispatch can asynchronously add/cancel work.
@@ -900,6 +1063,15 @@ export class RoutineManager {
900
1063
  this.save();
901
1064
  this.emitRun(run);
902
1065
  }
1066
+ const requiresCurrentDayOccurrence = definition.schedule.weekdays !== undefined || definition.schedule.window !== undefined;
1067
+ const mayDispatch = intervalAllowsOccurrence(definition.schedule, now) &&
1068
+ (!requiresCurrentDayOccurrence || (latest !== null && isSameLocalDay(latest, now)));
1069
+ if (!mayDispatch) {
1070
+ if (nextOccurrence(definition.schedule, now) === null) {
1071
+ this.missQueuedRun(run, "The routine ended before this scheduled run could start");
1072
+ }
1073
+ continue;
1074
+ }
903
1075
  }
904
1076
  const state = this.targetState(run);
905
1077
  if (state === "busy")
@@ -1071,6 +1243,15 @@ export class RoutineManager {
1071
1243
  this.emitRun(run);
1072
1244
  this.options.onRunFailed?.(cloneRun(run));
1073
1245
  }
1246
+ missQueuedRun(run, message) {
1247
+ run.status = "missed";
1248
+ run.attention = undefined;
1249
+ run.error = redactSecretsInText(message).slice(0, 500);
1250
+ run.finishedAt = this.now();
1251
+ this.save();
1252
+ this.emitRun(run);
1253
+ this.options.onRunFailed?.(cloneRun(run));
1254
+ }
1074
1255
  targetState(target) {
1075
1256
  if (target.target === "room-goal") {
1076
1257
  if (!target.groupId || !this.options.goalState)
@@ -1095,7 +1276,16 @@ export class RoutineManager {
1095
1276
  return schedule.at;
1096
1277
  return nextOccurrence(schedule, now);
1097
1278
  }
1098
- 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
+ }
1099
1289
  const run = {
1100
1290
  id: randomUUID(),
1101
1291
  routineId: routine.id,
@@ -1112,12 +1302,44 @@ export class RoutineManager {
1112
1302
  status: "queued",
1113
1303
  manual,
1114
1304
  triggerSource: manual ? "manual" : "schedule",
1115
- sourceThreadId: routine.sourceThreadId,
1305
+ sourceThreadId,
1306
+ resultsThreadId: routine.resultsThreadId,
1116
1307
  createdAt: this.now(),
1117
1308
  };
1118
1309
  this.runs.push(run);
1119
1310
  return run;
1120
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
+ }
1121
1343
  emitRoutine(routine) {
1122
1344
  this.options.emit?.({ kind: "routine", routine: cloneRoutine(routine) });
1123
1345
  }
@@ -1164,7 +1386,7 @@ export class RoutineManager {
1164
1386
  * state if writing or renaming that file fails so a retry cannot mistake an
1165
1387
  * uncommitted action for a durable one.
1166
1388
  */
1167
- commitMutation(mutate) {
1389
+ commitMutation(mutate, rollback) {
1168
1390
  const before = {
1169
1391
  routines: this.routines.map(cloneRoutine),
1170
1392
  runs: this.runs.map(cloneRun),
@@ -1178,6 +1400,12 @@ export class RoutineManager {
1178
1400
  this.routines = before.routines;
1179
1401
  this.runs = before.runs;
1180
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
+ }
1181
1409
  throw error;
1182
1410
  }
1183
1411
  }
@@ -18,6 +18,30 @@
18
18
  // the feature.
19
19
  import { newId } from "./contracts.js";
20
20
  const queues = new Map(); // threadId → waiting sends
21
+ const listeners = new Set();
22
+ const changed = () => {
23
+ for (const listener of listeners) {
24
+ try {
25
+ listener();
26
+ }
27
+ catch {
28
+ console.warn("steer-queue: change listener failed");
29
+ }
30
+ }
31
+ };
32
+ /** Public pending chips only: never expose provider prompts or reply context. */
33
+ export function queuedSteerSnapshot(ownsThread) {
34
+ return Object.fromEntries([...queues]
35
+ .filter(([threadId, entry]) => ownsThread(entry.botId, threadId))
36
+ .map(([threadId, entry]) => [threadId, entry.items.map((item) => ({
37
+ queueId: item.messageId, text: item.text, ...(item.reason ? { reason: item.reason } : {}),
38
+ }))]));
39
+ }
40
+ /** Publish changes synchronously so every client can restore/cancel the queue. */
41
+ export function onSteeredQueueChange(listener) {
42
+ listeners.add(listener);
43
+ return () => { listeners.delete(listener); };
44
+ }
21
45
  /** Hold a mid-turn send off the transcript until drain. */
22
46
  export function queueSteeredMessage(botId, threadId, text, options = {}) {
23
47
  const id = newId();
@@ -33,10 +57,27 @@ export function queueSteeredMessage(botId, threadId, text, options = {}) {
33
57
  replyToId: options.replyToId,
34
58
  sendId: options.sendId,
35
59
  reason: options.reason,
60
+ unattended: options.unattended,
36
61
  });
37
62
  queues.set(threadId, entry);
63
+ changed();
38
64
  return { id };
39
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
+ }
40
81
  /** Drain every queue whose task is idle: append the held lines (leaf is now
41
82
  * the finished turn's last item), then one run per thread whose prompt is
42
83
  * the texts separated by a blank line. `userMessage` is the last appended line
@@ -53,6 +94,7 @@ export function drainSteeredMessages(store, run, isBlocked) {
53
94
  if (!bot) {
54
95
  // the bot or task was deleted while messages waited
55
96
  queues.delete(threadId);
97
+ changed();
56
98
  continue;
57
99
  }
58
100
  if (bot.busy || isBlocked?.(entry.botId, threadId))
@@ -60,6 +102,7 @@ export function drainSteeredMessages(store, run, isBlocked) {
60
102
  // committed to draining: the entry leaves the map before anything runs,
61
103
  // so a settle racing another settle can never fire the same queue twice
62
104
  queues.delete(threadId);
105
+ changed();
63
106
  const appended = [];
64
107
  for (const item of entry.items) {
65
108
  // queueId is the pending-chip identity from the 202; append still
@@ -81,7 +124,10 @@ export function drainSteeredMessages(store, run, isBlocked) {
81
124
  // message into one HTML block, which makes that attachment stop being a
82
125
  // native image when the combined follow-up is dispatched.
83
126
  const prompt = entry.items.map((item) => item.prompt).join("\n\n");
84
- 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));
85
131
  }
86
132
  }
87
133
  /** Find the receipt for a retry whose message is still waiting to drain. */
@@ -107,6 +153,7 @@ export function cancelSteeredMessage(botId, messageId, expectedThreadId) {
107
153
  queues.delete(threadId);
108
154
  else
109
155
  queues.set(threadId, { botId: entry.botId, items });
156
+ changed();
110
157
  return true;
111
158
  }
112
159
  return false;