openmausbot 0.1.67 → 0.1.69

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-BwpNqgCm.js → index-BmHupgVJ.js} +1 -1
  2. package/dist/assets/index-COp5lMnv.js +279 -0
  3. package/dist/assets/index-Jk7TIeyW.css +1 -0
  4. package/dist/index.html +2 -2
  5. package/dist-server/container-mcp.js +40 -2
  6. package/dist-server/drivers/agents-proxy.js +112 -7
  7. package/dist-server/enterprise/server/index.js +14587 -0
  8. package/dist-server/index.js +1732 -681
  9. package/dist-server/openmausbot.js +939 -381
  10. package/dist-server/pair-cli.js +939 -381
  11. package/dist-server/server/avatar-image.js +86 -34
  12. package/dist-server/server/bot-package.js +60 -14
  13. package/dist-server/server/brand.js +7 -0
  14. package/dist-server/server/cli.js +23 -1
  15. package/dist-server/server/config.js +30 -3
  16. package/dist-server/server/delegations.js +56 -11
  17. package/dist-server/server/drivers/acp/qwen.js +142 -5
  18. package/dist-server/server/drivers/agents-proxy.js +135 -8
  19. package/dist-server/server/drivers/codex-device-auth.js +102 -3
  20. package/dist-server/server/drivers/codex-identity.js +115 -0
  21. package/dist-server/server/drivers/codex.js +6 -0
  22. package/dist-server/server/harness/registry.js +2 -0
  23. package/dist-server/server/index.js +220 -65
  24. package/dist-server/server/package-export.js +7 -0
  25. package/dist-server/server/provider-auth-sessions.js +24 -0
  26. package/dist-server/server/routine-requests.js +270 -25
  27. package/dist-server/server/routines.js +215 -21
  28. package/dist-server/server/service-cli.js +63 -0
  29. package/dist-server/server/service-unit.js +129 -0
  30. package/dist-server/server/steer-queue.js +30 -1
  31. package/dist-server/server/store.js +30 -4
  32. package/dist-server/server/system-prompt.js +1 -0
  33. package/dist-server/server/turn-dispatch-guard.js +6 -0
  34. package/dist-server/shared/image-generation.js +28 -0
  35. package/dist-server/vps-container-mcp.js +40 -2
  36. package/enterprise/LICENSE +32 -0
  37. package/enterprise/README.md +89 -0
  38. package/enterprise/server/index.js +14587 -0
  39. package/package.json +2 -1
  40. package/dist/assets/index-A3CBTxwl.css +0 -1
  41. package/dist/assets/index-CDuyoVPJ.js +0 -278
@@ -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);
@@ -449,6 +587,10 @@ export class RoutineManager {
449
587
  isActiveThread(threadId) {
450
588
  return this.runs.some((run) => run.threadId === threadId && ["running", "waiting"].includes(run.status));
451
589
  }
590
+ runForThread(threadId) {
591
+ const run = this.runs.find((candidate) => candidate.threadId === threadId);
592
+ return run ? cloneRun(run) : null;
593
+ }
452
594
  create(input, request) {
453
595
  if (request) {
454
596
  const receipt = this.matchingRoutineRequestReceipt(request);
@@ -463,13 +605,17 @@ export class RoutineManager {
463
605
  if (this.targetState(clean) === "missing")
464
606
  throw new Error(this.missingTargetMessage(clean.target));
465
607
  const at = this.now();
608
+ const nextRunAt = clean.enabled ? this.initialOccurrence(clean.schedule, at) : null;
609
+ if (clean.schedule.type === "interval" && clean.enabled && nextRunAt === null) {
610
+ throw new Error("This interval has no future runs. Choose a later end date or turn it off.");
611
+ }
466
612
  const routine = {
467
613
  id: randomUUID(),
468
614
  ...clean,
469
615
  // Only a confirmed chat card supplies `request`; the public calendar
470
616
  // API cannot choose an arbitrary transcript as a reporting target.
471
617
  sourceThreadId: request?.threadId,
472
- nextRunAt: clean.enabled ? this.initialOccurrence(clean.schedule, at) : null,
618
+ nextRunAt,
473
619
  createdAt: at,
474
620
  updatedAt: at,
475
621
  };
@@ -501,7 +647,7 @@ export class RoutineManager {
501
647
  groupId: Object.hasOwn(patch, "groupId") ? patch.groupId : routine.groupId,
502
648
  runOn: patch.runOn ?? routine.runOn,
503
649
  enabled: patch.enabled ?? routine.enabled,
504
- schedule: patch.schedule ?? routine.schedule,
650
+ schedule: patch.schedule ? mergeScheduleUpdate(routine.schedule, patch.schedule) : routine.schedule,
505
651
  durationMinutes: patch.durationMinutes ?? routine.durationMinutes,
506
652
  timeoutMinutes: Object.hasOwn(patch, "timeoutMinutes") ? patch.timeoutMinutes : routine.timeoutMinutes,
507
653
  attachments: patch.attachments ?? routine.attachments,
@@ -509,10 +655,19 @@ export class RoutineManager {
509
655
  });
510
656
  if (this.targetState(clean) === "missing")
511
657
  throw new Error(this.missingTargetMessage(clean.target));
658
+ const scheduleChanged = JSON.stringify(clean.schedule) !== JSON.stringify(routine.schedule);
659
+ const enabledChanged = clean.enabled !== routine.enabled;
660
+ // Definition-only edits retain due work and offline catch-up.
661
+ const nextRunAt = !clean.enabled ? null : scheduleChanged || enabledChanged
662
+ ? this.initialOccurrence(clean.schedule, now)
663
+ : routine.nextRunAt;
664
+ if (clean.schedule.type === "interval" && clean.enabled && nextRunAt === null) {
665
+ throw new Error("This interval has no future runs. Choose a later end date or turn it off.");
666
+ }
512
667
  const cancelledRuns = [];
513
668
  this.commitMutation(() => {
514
669
  Object.assign(routine, clean, {
515
- nextRunAt: clean.enabled ? this.initialOccurrence(clean.schedule, now) : null,
670
+ nextRunAt,
516
671
  // `updatedAt` doubles as the optimistic revision on durable routine
517
672
  // confirmation cards. Keep it monotonic even for two writes in one ms.
518
673
  updatedAt: Math.max(now, routine.updatedAt + 1),
@@ -861,6 +1016,10 @@ export class RoutineManager {
861
1016
  routine.enabled = false;
862
1017
  routine.updatedAt = Math.max(now, routine.updatedAt + 1);
863
1018
  }
1019
+ else if (routine.schedule.type === "interval" && routine.nextRunAt === null) {
1020
+ routine.enabled = false;
1021
+ routine.updatedAt = Math.max(now, routine.updatedAt + 1);
1022
+ }
864
1023
  this.emitRoutine(routine);
865
1024
  changed = true;
866
1025
  }
@@ -868,7 +1027,10 @@ export class RoutineManager {
868
1027
  this.save();
869
1028
  for (const missed of missedRuns)
870
1029
  this.options.onRunFailed?.(missed);
871
- for (const run of [...this.runs].reverse()) {
1030
+ // Oldest queued requests have priority. New manual/webhook arrivals
1031
+ // must not continually overtake work that has already waited. Snapshot
1032
+ // the queue because each dispatch can asynchronously add/cancel work.
1033
+ for (const run of this.runs.slice()) {
872
1034
  if (run.status !== "queued")
873
1035
  continue;
874
1036
  // A queued interval represents the latest useful check, not a backlog
@@ -887,6 +1049,15 @@ export class RoutineManager {
887
1049
  this.save();
888
1050
  this.emitRun(run);
889
1051
  }
1052
+ const requiresCurrentDayOccurrence = definition.schedule.weekdays !== undefined || definition.schedule.window !== undefined;
1053
+ const mayDispatch = intervalAllowsOccurrence(definition.schedule, now) &&
1054
+ (!requiresCurrentDayOccurrence || (latest !== null && isSameLocalDay(latest, now)));
1055
+ if (!mayDispatch) {
1056
+ if (nextOccurrence(definition.schedule, now) === null) {
1057
+ this.missQueuedRun(run, "The routine ended before this scheduled run could start");
1058
+ }
1059
+ continue;
1060
+ }
890
1061
  }
891
1062
  const state = this.targetState(run);
892
1063
  if (state === "busy")
@@ -949,9 +1120,19 @@ export class RoutineManager {
949
1120
  // completion and private coordinator envelope are only intermediate
950
1121
  // protocol, never the routine receipt's result.
951
1122
  if (run.target === "room-goal" &&
952
- (event.type === "turn.completed" || (event.type === "item.completed" && event.itemType === "assistant_text")))
1123
+ (
1124
+ // These outcomes ended the goal operation. Later room traffic is
1125
+ // not a resume of that run; only the goal lifecycle can change its
1126
+ // receipt. In-flight provider approvals have no goalStatus and
1127
+ // continue to resolve normally below.
1128
+ run.goalStatus === "needs-input" || run.goalStatus === "paused" ||
1129
+ event.type === "turn.completed" || (event.type === "item.completed" && event.itemType === "assistant_text")))
953
1130
  return null;
954
- if (event.type === "request.opened") {
1131
+ if (event.type === "turn.started") {
1132
+ run.status = "running";
1133
+ run.attention = undefined;
1134
+ }
1135
+ else if (event.type === "request.opened") {
955
1136
  run.status = "waiting";
956
1137
  run.attention = redactSecretsInText(event.summary).trim().slice(0, 500) || undefined;
957
1138
  }
@@ -971,16 +1152,20 @@ export class RoutineManager {
971
1152
  return null;
972
1153
  }
973
1154
  else if (event.type === "turn.completed") {
974
- run.cost = event.cost;
975
- run.denials = event.denials;
1155
+ if (event.cost != null)
1156
+ run.cost = (run.cost ?? 0) + event.cost;
1157
+ if (event.denials?.length)
1158
+ run.denials = [...new Set([...(run.denials ?? []), ...event.denials])];
976
1159
  if (!event.ok) {
977
1160
  this.failRun(run, event.stopReason ?? run.error ?? "The bot did not complete this run");
978
1161
  queueMicrotask(() => void this.tick());
979
1162
  return cloneRun(run);
980
1163
  }
981
- run.status = "completed";
982
- run.attention = undefined;
983
- run.finishedAt = this.now();
1164
+ const pending = this.options.hasPendingDelegations?.(event.threadId) === true;
1165
+ run.status = pending ? "waiting" : "completed";
1166
+ run.attention = pending ? "Waiting for delegated work to finish" : undefined;
1167
+ if (!pending)
1168
+ run.finishedAt = this.now();
984
1169
  run.error = undefined;
985
1170
  }
986
1171
  else {
@@ -1044,6 +1229,15 @@ export class RoutineManager {
1044
1229
  this.emitRun(run);
1045
1230
  this.options.onRunFailed?.(cloneRun(run));
1046
1231
  }
1232
+ missQueuedRun(run, message) {
1233
+ run.status = "missed";
1234
+ run.attention = undefined;
1235
+ run.error = redactSecretsInText(message).slice(0, 500);
1236
+ run.finishedAt = this.now();
1237
+ this.save();
1238
+ this.emitRun(run);
1239
+ this.options.onRunFailed?.(cloneRun(run));
1240
+ }
1047
1241
  targetState(target) {
1048
1242
  if (target.target === "room-goal") {
1049
1243
  if (!target.groupId || !this.options.goalState)
@@ -0,0 +1,63 @@
1
+ // The `openmausbot service` command, kept separate from cli.ts so it can be
2
+ // tested with explicit inputs: it renders the unit for this platform, writes
3
+ // it next to the data, and prints the commands that install it.
4
+ import { mkdirSync, writeFileSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { currentUser, launchdPlist, servicePlan, systemdUnit, unstableInstallWarning } from "./service-unit.js";
7
+ /** The `serve` arguments the service repeats, from the options given to `service install`. */
8
+ export function serviceServeArgs(input) {
9
+ const args = ["--port", String(input.port), "--data-dir", input.dataDir, "--no-pair"];
10
+ if (input.domain)
11
+ args.push("--domain", input.domain);
12
+ else if (input.tunnel)
13
+ args.push("--tunnel");
14
+ else if (input.tailscale)
15
+ args.push("--tailscale");
16
+ if (input.label)
17
+ args.push("--label", input.label);
18
+ return args;
19
+ }
20
+ export function runServiceCommand(input, io) {
21
+ const platform = input.platform ?? process.platform;
22
+ const home = input.home ?? homedir();
23
+ const plan = servicePlan(platform, input.dataDir, home);
24
+ if (!plan) {
25
+ io.error("services are written for Linux (systemd) and macOS (launchd); on Windows, use Task Scheduler to run `openmausbot serve` at startup");
26
+ return 1;
27
+ }
28
+ if (input.action === "uninstall") {
29
+ io.log(`to stop and remove the service:`);
30
+ for (const line of plan.deactivate)
31
+ io.log(` ${line}`);
32
+ return 0;
33
+ }
34
+ const warning = unstableInstallWarning(input.script);
35
+ if (warning) {
36
+ io.error(warning);
37
+ return 1;
38
+ }
39
+ const spec = {
40
+ node: input.node,
41
+ script: input.script,
42
+ serveArgs: serviceServeArgs(input),
43
+ dataDir: input.dataDir,
44
+ user: input.user ?? currentUser(),
45
+ home,
46
+ bindsLowPorts: Boolean(input.domain),
47
+ ...(input.label ? { label: input.label } : {}),
48
+ };
49
+ const rendered = platform === "darwin" ? launchdPlist(spec) : systemdUnit(spec);
50
+ mkdirSync(input.dataDir, { recursive: true, mode: 0o700 });
51
+ writeFileSync(plan.file, rendered, { mode: 0o644 });
52
+ io.log(`wrote ${plan.file}`);
53
+ io.log("");
54
+ io.log(platform === "darwin" ? "to install and start it (runs at login):" : "to install and start it (runs at boot, restarts if it stops):");
55
+ for (const line of plan.activate)
56
+ io.log(` ${line}`);
57
+ io.log("");
58
+ if (input.domain && platform === "linux")
59
+ io.log("the unit grants Caddy the capability for ports 80 and 443, so no setcap is needed under the service");
60
+ io.log(`logs: ${platform === "darwin" ? `${input.dataDir}/logs/service.log` : "journalctl -u openmausbot -f"}`);
61
+ io.log(`change options later by running \`openmausbot service install\` again with the new ones, then: ${platform === "darwin" ? plan.activate[1] : "sudo systemctl daemon-reload && sudo systemctl restart openmausbot"}`);
62
+ return 0;
63
+ }
@@ -0,0 +1,129 @@
1
+ // `openmausbot service install`: keep the server running across reboots.
2
+ // Renders a systemd unit (Linux) or a launchd agent (macOS) that runs the
3
+ // same `openmausbot serve …` the operator just used, and either installs it
4
+ // (when allowed to) or writes it next to the data and prints the two
5
+ // commands that install it. Pure rendering lives here so it is testable;
6
+ // the CLI decides where the file goes.
7
+ import { homedir, userInfo } from "node:os";
8
+ import { posix } from "node:path";
9
+ // Unit files describe a Linux or macOS machine, so their paths are POSIX
10
+ // whatever host renders them (the Windows CI runner included).
11
+ const { basename, dirname, join } = posix;
12
+ export const SYSTEMD_UNIT_NAME = "openmausbot.service";
13
+ export const LAUNCHD_LABEL = "com.openmausbot.serve";
14
+ function quoteSystemd(value) {
15
+ // systemd's ExecStart splits on whitespace and understands double quotes.
16
+ return /[\s"\\]/.test(value) ? `"${value.replace(/[\\"]/g, "\\$&")}"` : value;
17
+ }
18
+ function needsStripTypes(script) {
19
+ return script.endsWith(".ts");
20
+ }
21
+ export function serviceCommand(spec) {
22
+ return [spec.node, ...(needsStripTypes(spec.script) ? ["--experimental-strip-types"] : []), spec.script, "serve", ...spec.serveArgs];
23
+ }
24
+ export function systemdUnit(spec) {
25
+ const lines = [
26
+ "# Written by `openmausbot service install`. Re-run it to change the options.",
27
+ "[Unit]",
28
+ `Description=OpenMausBot${spec.label ? ` (${spec.label})` : ""}`,
29
+ "After=network-online.target",
30
+ "Wants=network-online.target",
31
+ "",
32
+ "[Service]",
33
+ "Type=simple",
34
+ `User=${spec.user}`,
35
+ `WorkingDirectory=${spec.home}`,
36
+ `Environment=HOME=${spec.home}`,
37
+ `Environment=OMB_DATA_DIR=${spec.dataDir}`,
38
+ `ExecStart=${serviceCommand(spec).map(quoteSystemd).join(" ")}`,
39
+ "Restart=always",
40
+ "RestartSec=3",
41
+ "KillMode=mixed",
42
+ "TimeoutStopSec=30",
43
+ ];
44
+ if (spec.bindsLowPorts) {
45
+ lines.push("# serve --domain: Caddy binds ports 80 and 443 without running as root.");
46
+ lines.push("AmbientCapabilities=CAP_NET_BIND_SERVICE");
47
+ lines.push("CapabilityBoundingSet=CAP_NET_BIND_SERVICE");
48
+ }
49
+ lines.push("", "[Install]", "WantedBy=multi-user.target", "");
50
+ return lines.join("\n");
51
+ }
52
+ function xml(value) {
53
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
54
+ }
55
+ export function launchdPlist(spec) {
56
+ const args = serviceCommand(spec).map((arg) => `\t\t<string>${xml(arg)}</string>`).join("\n");
57
+ return [
58
+ '<?xml version="1.0" encoding="UTF-8"?>',
59
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
60
+ '<plist version="1.0">',
61
+ "<dict>",
62
+ "\t<key>Label</key>",
63
+ `\t<string>${LAUNCHD_LABEL}</string>`,
64
+ "\t<key>ProgramArguments</key>",
65
+ "\t<array>",
66
+ args,
67
+ "\t</array>",
68
+ "\t<key>EnvironmentVariables</key>",
69
+ "\t<dict>",
70
+ "\t\t<key>HOME</key>",
71
+ `\t\t<string>${xml(spec.home)}</string>`,
72
+ "\t\t<key>OMB_DATA_DIR</key>",
73
+ `\t\t<string>${xml(spec.dataDir)}</string>`,
74
+ "\t\t<key>PATH</key>",
75
+ "\t\t<string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string>",
76
+ "\t</dict>",
77
+ "\t<key>WorkingDirectory</key>",
78
+ `\t<string>${xml(spec.home)}</string>`,
79
+ "\t<key>RunAtLoad</key>",
80
+ "\t<true/>",
81
+ "\t<key>KeepAlive</key>",
82
+ "\t<true/>",
83
+ "\t<key>StandardOutPath</key>",
84
+ `\t<string>${xml(join(spec.dataDir, "logs", "service.log"))}</string>`,
85
+ "\t<key>StandardErrorPath</key>",
86
+ `\t<string>${xml(join(spec.dataDir, "logs", "service.log"))}</string>`,
87
+ "</dict>",
88
+ "</plist>",
89
+ "",
90
+ ].join("\n");
91
+ }
92
+ /** An `npx` cache path is pruned without notice; a service must not point at it. */
93
+ export function unstableInstallWarning(script) {
94
+ const normalized = script.replace(/\\/g, "/");
95
+ if (/\/_npx\//.test(normalized) || /\/\.npm\/_npx\//.test(normalized)) {
96
+ return `this command runs from an npx cache (${dirname(script)}), which npm may delete at any time. Install it permanently first (npm install -g openmausbot) and run \`openmausbot service install\` from that install.`;
97
+ }
98
+ return null;
99
+ }
100
+ /** Where the rendered file goes and how to activate it, per platform. */
101
+ export function servicePlan(platform, dataDir, home = homedir()) {
102
+ if (platform === "linux") {
103
+ const installed = `/etc/systemd/system/${SYSTEMD_UNIT_NAME}`;
104
+ return {
105
+ file: join(dataDir, SYSTEMD_UNIT_NAME),
106
+ installed,
107
+ activate: [`sudo install -m 644 ${join(dataDir, SYSTEMD_UNIT_NAME)} ${installed}`, "sudo systemctl daemon-reload", `sudo systemctl enable --now ${basename(SYSTEMD_UNIT_NAME, ".service")}`],
108
+ deactivate: [`sudo systemctl disable --now ${basename(SYSTEMD_UNIT_NAME, ".service")}`, `sudo rm ${installed}`, "sudo systemctl daemon-reload"],
109
+ };
110
+ }
111
+ if (platform === "darwin") {
112
+ const installed = join(home, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
113
+ return {
114
+ file: join(dataDir, `${LAUNCHD_LABEL}.plist`),
115
+ installed,
116
+ activate: [`mkdir -p ${dirname(installed)} && cp ${join(dataDir, `${LAUNCHD_LABEL}.plist`)} ${installed}`, `launchctl bootstrap gui/$(id -u) ${installed}`],
117
+ deactivate: [`launchctl bootout gui/$(id -u)/${LAUNCHD_LABEL}`, `rm ${installed}`],
118
+ };
119
+ }
120
+ return null;
121
+ }
122
+ export function currentUser() {
123
+ try {
124
+ return userInfo().username;
125
+ }
126
+ catch {
127
+ return process.env.USER || process.env.USERNAME || "openmausbot";
128
+ }
129
+ }
@@ -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();
@@ -32,8 +56,10 @@ export function queueSteeredMessage(botId, threadId, text, options = {}) {
32
56
  prompt: options.prompt ?? text,
33
57
  replyToId: options.replyToId,
34
58
  sendId: options.sendId,
59
+ reason: options.reason,
35
60
  });
36
61
  queues.set(threadId, entry);
62
+ changed();
37
63
  return { id };
38
64
  }
39
65
  /** Drain every queue whose task is idle: append the held lines (leaf is now
@@ -52,6 +78,7 @@ export function drainSteeredMessages(store, run, isBlocked) {
52
78
  if (!bot) {
53
79
  // the bot or task was deleted while messages waited
54
80
  queues.delete(threadId);
81
+ changed();
55
82
  continue;
56
83
  }
57
84
  if (bot.busy || isBlocked?.(entry.botId, threadId))
@@ -59,6 +86,7 @@ export function drainSteeredMessages(store, run, isBlocked) {
59
86
  // committed to draining: the entry leaves the map before anything runs,
60
87
  // so a settle racing another settle can never fire the same queue twice
61
88
  queues.delete(threadId);
89
+ changed();
62
90
  const appended = [];
63
91
  for (const item of entry.items) {
64
92
  // queueId is the pending-chip identity from the 202; append still
@@ -89,7 +117,7 @@ export function queuedSteeredMessage(botId, threadId, sendId) {
89
117
  if (!entry || entry.botId !== botId)
90
118
  return null;
91
119
  const item = entry.items.find((candidate) => candidate.sendId === sendId);
92
- return item ? { id: item.messageId, text: item.text, replyToId: item.replyToId } : null;
120
+ return item ? { id: item.messageId, text: item.text, replyToId: item.replyToId, ...(item.reason ? { reason: item.reason } : {}) } : null;
93
121
  }
94
122
  /** Drop one waiting send owned by this bot so it never drains. The queue id
95
123
  * is stable even if the bot switches away from the task while the request is
@@ -106,6 +134,7 @@ export function cancelSteeredMessage(botId, messageId, expectedThreadId) {
106
134
  queues.delete(threadId);
107
135
  else
108
136
  queues.set(threadId, { botId: entry.botId, items });
137
+ changed();
109
138
  return true;
110
139
  }
111
140
  return false;