openmausbot 0.1.76 → 0.1.78

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 (64) hide show
  1. package/dist/assets/{index-C6blGSq5.js → index-BqDJqf2C.js} +1 -1
  2. package/dist/assets/index-DjwAroIZ.js +305 -0
  3. package/dist/assets/index-Pbb6Ao0s.css +1 -0
  4. package/dist/index.html +2 -2
  5. package/dist-server/container-mcp.js +5 -1
  6. package/dist-server/drivers/agents-proxy.js +838 -30
  7. package/dist-server/enterprise/server/index.js +387 -29
  8. package/dist-server/index.js +4097 -2063
  9. package/dist-server/openmausbot.js +730 -523
  10. package/dist-server/pair-cli.js +730 -523
  11. package/dist-server/server/agent-tool-policy.js +1 -0
  12. package/dist-server/server/auto-approve.js +16 -0
  13. package/dist-server/server/bot-overview.js +6 -2
  14. package/dist-server/server/bot-package.js +17 -3
  15. package/dist-server/server/browser-live.js +3 -0
  16. package/dist-server/server/chief-of-staff.js +2 -2
  17. package/dist-server/server/config.js +16 -0
  18. package/dist-server/server/drivers/acp/core.js +3 -1
  19. package/dist-server/server/drivers/agents-proxy.js +132 -27
  20. package/dist-server/server/drivers/boxagent.js +1 -1
  21. package/dist-server/server/drivers/claude.js +3 -2
  22. package/dist-server/server/drivers/codex.js +3 -1
  23. package/dist-server/server/drivers/pi.js +4 -0
  24. package/dist-server/server/enterprise.js +38 -0
  25. package/dist-server/server/environment.js +3 -0
  26. package/dist-server/server/fleet-agent.js +57 -13
  27. package/dist-server/server/fleet-cli.js +248 -31
  28. package/dist-server/server/fleet.js +88 -14
  29. package/dist-server/server/hosted-contract.js +16 -0
  30. package/dist-server/server/index.js +877 -197
  31. package/dist-server/server/message-file.js +4 -0
  32. package/dist-server/server/package-export.js +16 -14
  33. package/dist-server/server/peer-approval.js +1 -1
  34. package/dist-server/server/profile-requests.js +26 -4
  35. package/dist-server/server/provider-key-check.js +18 -1
  36. package/dist-server/server/request-auth.js +14 -5
  37. package/dist-server/server/room-handoffs.js +38 -1
  38. package/dist-server/server/routine-requests.js +86 -3
  39. package/dist-server/server/routines.js +31 -13
  40. package/dist-server/server/sessions.js +115 -8
  41. package/dist-server/server/setup-mode.js +11 -13
  42. package/dist-server/server/shared-computer-control.js +24 -0
  43. package/dist-server/server/shared-computers.js +131 -0
  44. package/dist-server/server/skill-learn.js +4 -4
  45. package/dist-server/server/skills.js +21 -0
  46. package/dist-server/server/steer-queue.js +2 -0
  47. package/dist-server/server/store.js +110 -8
  48. package/dist-server/server/system-prompt.js +7 -5
  49. package/dist-server/server/team-backup.js +6 -2
  50. package/dist-server/server/team-computers.js +108 -0
  51. package/dist-server/server/team-setup-requests.js +83 -31
  52. package/dist-server/server/tool-summary.js +53 -1
  53. package/dist-server/server/workspace-backup.js +1 -0
  54. package/dist-server/shared/approval-mode.js +4 -1
  55. package/dist-server/shared/cron-label.js +39 -0
  56. package/dist-server/shared/routine-schedule.js +89 -0
  57. package/dist-server/shared/team-backup.js +1 -1
  58. package/dist-server/shared/team-computer.js +1 -0
  59. package/dist-server/vps-container-mcp.js +5 -1
  60. package/enterprise/README.md +21 -3
  61. package/enterprise/server/index.js +387 -29
  62. package/package.json +1 -1
  63. package/dist/assets/index-BtT047dk.css +0 -1
  64. package/dist/assets/index-fKXFpB_J.js +0 -305
@@ -326,6 +326,10 @@ function mimeFor(path) {
326
326
  case ".jpeg": return "image/jpeg";
327
327
  case ".gif": return "image/gif";
328
328
  case ".webp": return "image/webp";
329
+ case ".mp4": return "video/mp4";
330
+ case ".m4v": return "video/x-m4v";
331
+ case ".webm": return "video/webm";
332
+ case ".mov": return "video/quicktime";
329
333
  case ".doc": return "application/msword";
330
334
  case ".docx": return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
331
335
  case ".xls": return "application/vnd.ms-excel";
@@ -111,20 +111,22 @@ export function createBotPackageExport(input) {
111
111
  runOn: routine.runOn,
112
112
  schedule: routine.schedule.type === "once"
113
113
  ? { type: "once", at: routine.schedule.at }
114
- : routine.schedule.type === "interval"
115
- ? {
116
- type: "interval",
117
- everyMinutes: routine.schedule.everyMinutes,
118
- anchorAt: routine.schedule.anchorAt,
119
- ...(routine.schedule.weekdays === undefined
120
- ? {}
121
- : { weekdays: [...routine.schedule.weekdays] }),
122
- ...(routine.schedule.window === undefined
123
- ? {}
124
- : { window: { ...routine.schedule.window } }),
125
- ...(routine.schedule.endsAt === undefined ? {} : { endsAt: routine.schedule.endsAt }),
126
- }
127
- : { type: "daily", time: routine.schedule.time, weekdays: [...routine.schedule.weekdays] },
114
+ : routine.schedule.type === "cron"
115
+ ? { ...routine.schedule }
116
+ : routine.schedule.type === "interval"
117
+ ? {
118
+ type: "interval",
119
+ everyMinutes: routine.schedule.everyMinutes,
120
+ anchorAt: routine.schedule.anchorAt,
121
+ ...(routine.schedule.weekdays === undefined
122
+ ? {}
123
+ : { weekdays: [...routine.schedule.weekdays] }),
124
+ ...(routine.schedule.window === undefined
125
+ ? {}
126
+ : { window: { ...routine.schedule.window } }),
127
+ ...(routine.schedule.endsAt === undefined ? {} : { endsAt: routine.schedule.endsAt }),
128
+ }
129
+ : { type: "daily", time: routine.schedule.time, weekdays: [...routine.schedule.weekdays] },
128
130
  durationMinutes: routine.durationMinutes,
129
131
  ...(routine.timeoutMinutes === undefined ? {} : { timeoutMinutes: routine.timeoutMinutes }),
130
132
  enabledAfterInstall: false,
@@ -106,7 +106,7 @@ function announceCard(bus, from, card, sourceThreadId) {
106
106
  * covers the (action, target) pair, returns `"allow"` immediately
107
107
  * without a card. */
108
108
  export function requestPeerApproval(bus, from, target, message, action, sourceThreadId = from.threadId) {
109
- if (allowKeyAllowed(from, peerAllowKey(action, target.id))) {
109
+ if (bus.autoApply?.(from.id, sourceThreadId) || allowKeyAllowed(from, peerAllowKey(action, target.id))) {
110
110
  return Promise.resolve("allow");
111
111
  }
112
112
  return new Promise((resolve) => {
@@ -1,5 +1,6 @@
1
1
  // propose_profile: a bot proposes changes to its own name, title, description,
2
- // SOUL.md, or working folder; the change lands only when the user confirms the card. Same
2
+ // SOUL.md, or working folder; confirmed cards and Full Access submissions share
3
+ // the same validated commit path. Same
3
4
  // shape as routine-requests.ts, much smaller: the profile commits in one
4
5
  // store call, and staleness is a hash of the five fields instead of a
5
6
  // scheduler revision. Everything here is re-validated at confirm time —
@@ -163,6 +164,7 @@ export class ProfileRequestService {
163
164
  store;
164
165
  now;
165
166
  canPersist;
167
+ autoApply;
166
168
  /** Public: a caller's section membership can change between propose and
167
169
  * confirm, and tests flip this mid-scenario to model that. */
168
170
  validateTarget;
@@ -170,9 +172,17 @@ export class ProfileRequestService {
170
172
  this.store = options.store;
171
173
  this.now = options.now ?? Date.now;
172
174
  this.canPersist = options.canPersist;
175
+ this.autoApply = options.autoApply;
173
176
  this.validateTarget = options.validateTarget;
174
177
  }
175
178
  propose(args) {
179
+ return this.prepare(args);
180
+ }
181
+ submit(args) {
182
+ const proposal = this.prepare(args, true);
183
+ return { ...proposal, state: proposal.result ? "applied" : "pending" };
184
+ }
185
+ prepare(args, submitted = false) {
176
186
  const reason = reasonText(args.reason);
177
187
  const changes = parseChanges(args.changes);
178
188
  const targetBotId = args.targetBotId ?? args.botId;
@@ -220,13 +230,15 @@ export class ProfileRequestService {
220
230
  if (persistence && !persistence.ok) {
221
231
  throw new ProfileRequestError(persistence.error, persistence.status);
222
232
  }
233
+ const automatic = submitted && this.autoApply?.(args.botId, args.threadId) === true;
223
234
  const messageInput = {
224
235
  role: "bot",
225
236
  kind: "options",
226
237
  card: {
227
238
  title: copy.title,
228
239
  subtitle: copy.detail,
229
- options: ["Confirm", "Cancel"],
240
+ options: automatic ? [] : ["Confirm", "Cancel"],
241
+ ...(automatic ? { dismissed: true } : {}),
230
242
  requestId,
231
243
  tool: "update_profile",
232
244
  profileRequest: payload,
@@ -235,7 +247,15 @@ export class ProfileRequestService {
235
247
  if (args.from)
236
248
  messageInput.from = args.from;
237
249
  const message = this.store.appendMessage(args.threadId, messageInput);
238
- return { requestId, messageId: message.id, title: copy.title, summary: copy.summary, detail: copy.detail };
250
+ const proposal = { requestId, messageId: message.id, title: copy.title, summary: copy.summary, detail: copy.detail };
251
+ if (!automatic)
252
+ return proposal;
253
+ // The hidden receipt is persisted before the profile changes. The same
254
+ // validation and durable commit marker serve both automatic and human decisions.
255
+ const result = this.resolve({ botId: args.botId, threadId: args.threadId, requestId, behavior: "allow" });
256
+ if (result.state === "applied")
257
+ return { ...proposal, result };
258
+ throw new ProfileRequestError(result.state === "invalid" ? result.error : "The profile change could not be applied", result.state === "invalid" ? result.status : 409);
239
259
  }
240
260
  /** Claims a profile card even after it was settled, so a duplicate click
241
261
  * never re-applies an already-applied change. */
@@ -313,7 +333,9 @@ export class ProfileRequestService {
313
333
  const status = error instanceof ProfileRequestError ? error.status : 400;
314
334
  const detail = error instanceof Error ? error.message : String(error);
315
335
  const saved = this.store.bot(payload.targetBotId)?.lastProfileRequestId === payload.requestId;
316
- const notice = "Profile saved. Confirm again to finish recording this decision; the changes will not be applied again.";
336
+ const notice = card.dismissed && card.options.length === 0
337
+ ? "Profile saved. Recording the operation receipt could not finish; the changes will not be applied again."
338
+ : "Profile saved. Confirm again to finish recording this decision; the changes will not be applied again.";
317
339
  try {
318
340
  this.store.patchMessage(args.threadId, message.id, {
319
341
  card: { ...card, held: saved ? notice : redactSecretsInText(detail).slice(0, 500) },
@@ -31,6 +31,8 @@ function modelIds(body) {
31
31
  return ids;
32
32
  }
33
33
  export async function checkProviderKey(input, fetchImpl = fetch, timeoutMs = 10_000) {
34
+ if (!input.key.trim())
35
+ return { ok: false, reason: "rejected" };
34
36
  let url;
35
37
  try {
36
38
  url = new URL(providerModelsUrl(input.provider, input.url));
@@ -41,6 +43,10 @@ export async function checkProviderKey(input, fetchImpl = fetch, timeoutMs = 10_
41
43
  if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopback(url.hostname))) {
42
44
  return { ok: false, reason: "unexpected" };
43
45
  }
46
+ const authenticate = input.provider === "openaiCompat"
47
+ && url.origin === "https://openrouter.ai" && url.pathname === "/api/v1/models";
48
+ if (authenticate)
49
+ url.pathname = "/api/v1/key";
44
50
  const headers = input.provider === "anthropic"
45
51
  ? { "x-api-key": input.key, "anthropic-version": "2023-06-01" }
46
52
  : { authorization: `Bearer ${input.key}` };
@@ -55,7 +61,18 @@ export async function checkProviderKey(input, fetchImpl = fetch, timeoutMs = 10_
55
61
  if (!response.ok)
56
62
  return { ok: false, reason: "unexpected", status: response.status };
57
63
  const body = await response.json().catch(() => null);
58
- return { ok: true, models: modelIds(body) };
64
+ if (!body || typeof body !== "object")
65
+ return { ok: false, reason: "unexpected", status: response.status };
66
+ if (authenticate) {
67
+ const data = body.data;
68
+ if (!data || typeof data !== "object" || Array.isArray(data)
69
+ || typeof data.is_free_tier !== "boolean") {
70
+ return { ok: false, reason: "unexpected", status: response.status };
71
+ }
72
+ // /key also returns account and usage details; none belong in this verdict.
73
+ return { ok: true, check: "authentication", models: [] };
74
+ }
75
+ return { ok: true, check: "models", models: modelIds(body) };
59
76
  }
60
77
  catch {
61
78
  return { ok: false, reason: "unreachable" };
@@ -157,6 +157,11 @@ export const CLIENT_ALLOW = [
157
157
  { methods: ["GET"], path: /^\/api\/auth\/session$/ },
158
158
  { methods: ["POST"], path: /^\/api\/auth\/stream-ticket$/ },
159
159
  { methods: ["POST"], path: /^\/api\/auth\/logout$/ },
160
+ // Own outbound desktop connector, additionally bound to a private secret.
161
+ // Only honoured while features.sharedComputers is on (see requiredScope):
162
+ // with the feature off these paths are as unlisted as any other, so a
163
+ // client session is refused exactly the way an unknown route refuses it.
164
+ { methods: ["POST"], path: /^\/api\/shared-computers\/(?:connect|[\w-]+\/(?:poll|lease|result|disconnect))$/, feature: "sharedComputers" },
160
165
  // liveness, identity, the stream
161
166
  { methods: ["GET"], path: /^\/api\/health$/ },
162
167
  { methods: ["GET"], path: /^\/api\/edition$/ },
@@ -218,9 +223,11 @@ export const CLIENT_ALLOW = [
218
223
  // configured-or-not booleans; the handler strips the few identifying fields for clients
219
224
  { methods: ["GET"], path: /^\/api\/config$/ },
220
225
  ];
221
- export function requiredScope(method, path) {
226
+ export function requiredScope(method, path, features = {}) {
222
227
  const upper = method.toUpperCase();
223
228
  for (const rule of CLIENT_ALLOW) {
229
+ if (rule.feature && features[rule.feature] !== true)
230
+ continue;
224
231
  if (rule.path.test(path) && rule.methods.includes(upper))
225
232
  return "client";
226
233
  }
@@ -299,7 +306,7 @@ export function resolveRequestAuth(req, options) {
299
306
  if (session && via) {
300
307
  if (via === "cookie" && !isSameOrigin(req))
301
308
  return deny(403, "forbidden: cross-origin request");
302
- const needed = requiredScope(method, path);
309
+ const needed = requiredScope(method, path, options.features ?? {});
303
310
  if (!session.scopes.includes(needed)) {
304
311
  return deny(403, `forbidden: this session lacks the ${needed} scope`);
305
312
  }
@@ -311,6 +318,11 @@ export function resolveRequestAuth(req, options) {
311
318
  options.sessions.renew(session.id);
312
319
  return { auth: { kind: "session", session, via, scopes: session.scopes }, status: 401, error: "" };
313
320
  }
321
+ // A removed email member must not become the loopback owner merely because
322
+ // their now-invalid cookie or bearer was presented to a local address.
323
+ if (via) {
324
+ return deny(401, "unauthorized: this session has expired or was revoked; pair this device again");
325
+ }
314
326
  const proxied = isProxied(req);
315
327
  const loopback = !proxied && isLoopbackHost(headerValue(req.headers.host)) && isAllowedOrigin(headerValue(req.headers.origin));
316
328
  if (loopback) {
@@ -330,9 +342,6 @@ export function resolveRequestAuth(req, options) {
330
342
  }
331
343
  return { auth: { kind: "loopback", scopes: LOOPBACK_SCOPES }, status: 401, error: "" };
332
344
  }
333
- if (via) {
334
- return deny(401, "unauthorized: this session has expired or was revoked; pair this device again");
335
- }
336
345
  if (proxied) {
337
346
  return deny(403, "forbidden: this request came through a proxy (pair this device to use the server remotely)");
338
347
  }
@@ -172,6 +172,41 @@ export class RoomHandoffs {
172
172
  activeDirect(threadId) {
173
173
  return [...this.nodes.values()].some(n => !n.groupId && n.threadId === threadId && !terminal(n));
174
174
  }
175
+ /** Work this conversation handed out that has not settled yet. The
176
+ * conversation's own node is not outstanding — only what it waits on. */
177
+ outstandingDirect(threadId) {
178
+ return [...this.nodes.values()].filter(node => {
179
+ if (terminal(node) || !node.parentId)
180
+ return false;
181
+ const parent = this.nodes.get(node.parentId);
182
+ return Boolean(parent && !parent.groupId && parent.threadId === threadId);
183
+ });
184
+ }
185
+ /** Stop this conversation without reaching into a teammate that is already
186
+ * working. Its provider process is left alone: it finishes and its result
187
+ * is still reported here. Work that never started is cancelled, because
188
+ * nothing is lost. This conversation stops being awaited either way, so no
189
+ * teammate result resumes a stopped chat. Returns what was left running. */
190
+ stopAwaitingDirect(threadId, reason = "Stopped by user") {
191
+ const left = [];
192
+ for (const node of this.nodes.values()) {
193
+ if (node.groupId || node.threadId !== threadId || terminal(node))
194
+ continue;
195
+ for (const child of this.children(node.id)) {
196
+ if (terminal(child))
197
+ continue;
198
+ if (child.status === "queued")
199
+ this.cancelTree(child, "Stopped before it started");
200
+ else
201
+ left.push(child);
202
+ }
203
+ node.status = "cancelled";
204
+ node.result = reason;
205
+ this.controllers.get(node.id)?.abort();
206
+ this.publish(node);
207
+ }
208
+ return left;
209
+ }
175
210
  tick() {
176
211
  if (this.loadError)
177
212
  return;
@@ -200,7 +235,9 @@ export class RoomHandoffs {
200
235
  // A newly queued child starts only after its author has settled.
201
236
  if (parent && (parent.status === "source" || parent.status === "running"))
202
237
  continue;
203
- if (parent && terminal(parent)) {
238
+ // A stopped source stops waiting; only work that never started is
239
+ // dropped with it. A teammate mid-turn keeps its process and reports.
240
+ if (parent && terminal(parent) && n.status === "queued") {
204
241
  this.cancelTree(n, "Originating request has ended");
205
242
  continue;
206
243
  }
@@ -1,5 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { z } from "zod";
3
+ import { cronScheduleLabel } from "../shared/cron-label.js";
4
+ import { normalizeCronSchedule, nextCronRuns } from "../shared/routine-schedule.js";
3
5
  import { newId } from "./contracts.js";
4
6
  import { redactSecretsInText } from "./redact.js";
5
7
  import { parseJson, schemaIssue } from "./schema.js";
@@ -33,6 +35,7 @@ const toolIntervalWindowSchema = z.object({
33
35
  end: z.string().max(5),
34
36
  }).strict();
35
37
  const routineToolScheduleSchema = z.discriminatedUnion("type", [
38
+ z.object({ type: z.literal("cron"), expression: z.string().max(256), timeZone: z.string().max(128) }).strict(),
36
39
  z.object({ type: z.literal("once"), at: z.string().max(64) }).strict(),
37
40
  z.object({
38
41
  type: z.literal("weekly"),
@@ -82,7 +85,18 @@ const storedIntervalWindowSchema = z.object({
82
85
  start: z.string().regex(TIME),
83
86
  end: z.string().regex(TIME),
84
87
  }).strict().refine(({ start, end }) => start < end, "Stored interval window must end later on the same day");
88
+ const storedCronScheduleSchema = z.object({
89
+ type: z.literal("cron"), expression: z.string().max(256), timeZone: z.string().max(128),
90
+ }).strict().superRefine((schedule, context) => {
91
+ try {
92
+ normalizeCronSchedule(schedule);
93
+ }
94
+ catch (error) {
95
+ context.addIssue({ code: "custom", message: error instanceof Error ? error.message : "Invalid stored cron schedule" });
96
+ }
97
+ });
85
98
  const storedScheduleSchema = z.discriminatedUnion("type", [
99
+ storedCronScheduleSchema,
86
100
  z.object({ type: z.literal("once"), at: z.number().int().nonnegative() }).strict(),
87
101
  z.object({
88
102
  type: z.literal("daily"),
@@ -116,6 +130,7 @@ const storedScheduleSchema = z.discriminatedUnion("type", [
116
130
  }
117
131
  });
118
132
  const storedScheduleChangesSchema = z.discriminatedUnion("type", [
133
+ storedCronScheduleSchema,
119
134
  z.object({ type: z.literal("once"), at: z.number().int().nonnegative() }).strict(),
120
135
  z.object({
121
136
  type: z.literal("daily"),
@@ -287,6 +302,14 @@ function rfc3339Instant(value, offsetMessage) {
287
302
  return at;
288
303
  }
289
304
  function normalizeSchedule(schedule, now) {
305
+ if (schedule.type === "cron") {
306
+ try {
307
+ return normalizeCronSchedule(schedule, now);
308
+ }
309
+ catch (error) {
310
+ throw new RoutineRequestError(error instanceof Error ? error.message : "Invalid cron schedule");
311
+ }
312
+ }
290
313
  if (schedule.type === "once") {
291
314
  const at = rfc3339Instant(schedule.at, "One-time schedules need an RFC3339 date-time with an explicit timezone offset");
292
315
  if (at <= now)
@@ -424,6 +447,8 @@ function normalizedOperation(manager, botId, validated, now) {
424
447
  return { action: validated.action, routineId: id, expectedUpdatedAt: current.updatedAt };
425
448
  }
426
449
  function asSchedule(schedule, now) {
450
+ if (schedule.type === "cron")
451
+ return { ...schedule };
427
452
  if (schedule.type === "once")
428
453
  return { type: "once", at: schedule.at };
429
454
  if (schedule.type === "interval") {
@@ -455,6 +480,8 @@ function schedulePatch(schedule, now, current) {
455
480
  };
456
481
  }
457
482
  function effectiveSchedule(current, incoming) {
483
+ if (incoming.type === "cron")
484
+ return { ...incoming };
458
485
  if (incoming.type !== "interval") {
459
486
  return incoming.type === "once"
460
487
  ? { type: "once", at: incoming.at }
@@ -524,6 +551,8 @@ function intervalHasRestrictions(schedule) {
524
551
  return schedule.weekdays !== undefined || schedule.window !== undefined || schedule.endsAt !== undefined;
525
552
  }
526
553
  export function scheduleText(schedule, timeZone) {
554
+ if (schedule.type === "cron")
555
+ return `${cronScheduleLabel(schedule)} · Cron: ${schedule.expression}`;
527
556
  if (schedule.type === "once")
528
557
  return `${formatInstant(schedule.at, timeZone)} (${timeZone})`;
529
558
  if (schedule.type === "interval") {
@@ -552,6 +581,8 @@ export function consequenceLine(schedule, continuity = false) {
552
581
  // over is the previous run's report, so say that rather than contradict
553
582
  // the Continuity line above it.
554
583
  const session = continuity ? "each run starts a fresh session with the previous run's report" : "each run starts a fresh session";
584
+ if (schedule.type === "cron")
585
+ return `Will run at matching calendar times in ${schedule.timeZone}; ${session}.`;
555
586
  if (schedule.type === "once")
556
587
  return "Will run once; that run starts a fresh session.";
557
588
  if (schedule.type === "interval") {
@@ -621,6 +652,7 @@ function cardCopy(operation, manager, timeZone, now) {
621
652
  };
622
653
  }
623
654
  const nextRunAt = nextForOperation(operation, manager, now);
655
+ const scheduleTimeZone = definition.schedule.type === "cron" ? definition.schedule.timeZone : timeZone;
624
656
  const when = operation.action === "run_now" ? "Now" : scheduleText(definition.schedule, timeZone);
625
657
  const destination = definition.runOn === "cloud" ? "Cloud VM" : "This OpenMausBot setup";
626
658
  const current = operation.action === "create"
@@ -638,7 +670,7 @@ function cardCopy(operation, manager, timeZone, now) {
638
670
  ? `Next allowed time after confirmation (${timeZone})`
639
671
  : "One interval after confirmation"
640
672
  : nextRunAt !== null
641
- ? formatInstant(nextRunAt, timeZone)
673
+ ? formatInstant(nextRunAt, scheduleTimeZone)
642
674
  : operation.action === "pause"
643
675
  ? "None — this routine will be paused"
644
676
  : operation.action === "delete"
@@ -661,6 +693,9 @@ function cardCopy(operation, manager, timeZone, now) {
661
693
  ...(forBot ? [`For: @${redactSecretsInText(forBot.name)} — each run uses that bot's engine and permissions`] : []),
662
694
  `Schedule: ${when}`,
663
695
  `Next run: ${nextDescription}`,
696
+ ...(definition.schedule.type === "cron" && nextRunAt !== null && operation.action !== "run_now"
697
+ ? [`Next 3 runs (${scheduleTimeZone}): ${nextCronRuns(definition.schedule, now, 3).map((at) => formatInstant(at, scheduleTimeZone)).join(" · ")}`]
698
+ : []),
664
699
  `Runs on: ${destination}`,
665
700
  `Run limit: ${definition.timeoutMinutes === undefined ? "No limit" : `${definition.timeoutMinutes} minutes`}`,
666
701
  `Continuity: ${definition.continuity ? "Carries the previous run's report into the next run" : "Each run starts fresh"}`,
@@ -766,6 +801,14 @@ function revalidateOperation(operation, manager, botId, now) {
766
801
  : operation.action === "update"
767
802
  ? operation.changes.schedule
768
803
  : undefined;
804
+ if (schedule?.type === "cron") {
805
+ try {
806
+ normalizeCronSchedule(schedule, now);
807
+ }
808
+ catch (error) {
809
+ throw new RoutineRequestError(error instanceof Error ? error.message : "Invalid cron schedule", 409);
810
+ }
811
+ }
769
812
  if (schedule?.type === "once" && schedule.at <= now) {
770
813
  throw new RoutineRequestError("That one-time schedule is now in the past. Ask the bot to propose a new time.", 409);
771
814
  }
@@ -810,6 +853,7 @@ export class RoutineRequestService {
810
853
  cloudReady;
811
854
  canPersist;
812
855
  validateTarget;
856
+ autoApply;
813
857
  constructor(options) {
814
858
  this.store = options.store;
815
859
  this.routines = options.routines;
@@ -818,8 +862,16 @@ export class RoutineRequestService {
818
862
  this.cloudReady = options.cloudReady;
819
863
  this.canPersist = options.canPersist;
820
864
  this.validateTarget = options.validateTarget;
865
+ this.autoApply = options.autoApply;
821
866
  }
822
867
  async propose(args) {
868
+ return this.prepare(args);
869
+ }
870
+ async submit(args) {
871
+ const proposal = await this.prepare(args, true);
872
+ return { ...proposal, state: proposal.result ? "applied" : "pending" };
873
+ }
874
+ async prepare(args, submitted = false) {
823
875
  const botId = text(args.botId, "botId", 128);
824
876
  const threadId = text(args.threadId, "threadId", 128);
825
877
  const at = this.now();
@@ -848,7 +900,8 @@ export class RoutineRequestService {
848
900
  createdAt: cardAt,
849
901
  operation,
850
902
  };
851
- const timeZone = this.timeZone();
903
+ const definition = effectiveDefinition(operation, this.routines);
904
+ const timeZone = definition?.schedule.type === "cron" ? definition.schedule.timeZone : this.timeZone();
852
905
  const copy = cardCopy(operation, this.routines, timeZone, cardAt);
853
906
  const messageInput = {
854
907
  role: "bot",
@@ -874,8 +927,14 @@ export class RoutineRequestService {
874
927
  if (args.canCommit && !args.canCommit()) {
875
928
  throw new RoutineRequestError("The requesting turn ended before this proposal could be saved", 401);
876
929
  }
930
+ // Resolve the current source-thread grant after the asynchronous probe.
931
+ const automatic = submitted && this.autoApply?.(botId, threadId) === true;
932
+ if (automatic) {
933
+ messageInput.card.options = [];
934
+ messageInput.card.dismissed = true;
935
+ }
877
936
  const message = this.store.appendMessage(threadId, messageInput);
878
- return {
937
+ const proposal = {
879
938
  requestId,
880
939
  messageId: message.id,
881
940
  title: copy.title,
@@ -884,6 +943,30 @@ export class RoutineRequestService {
884
943
  nextRunAt: copy.nextRunAt,
885
944
  timeZone,
886
945
  };
946
+ if (!automatic)
947
+ return proposal;
948
+ // Persist a hidden receipt first, then use the existing validated,
949
+ // idempotent commit path without exposing a pending confirmation.
950
+ let result;
951
+ try {
952
+ result = this.resolve({ botId, threadId, requestId, behavior: "allow" });
953
+ }
954
+ catch (error) {
955
+ result = { claimed: true, state: "invalid", error: error instanceof Error ? error.message : String(error), status: error instanceof RoutineRequestError ? error.status : 400 };
956
+ }
957
+ if (result.state === "applied")
958
+ return { ...proposal, result };
959
+ // The scheduler commit can succeed even if settling its transcript
960
+ // fails. Report that exact result; a retry only finishes the receipt.
961
+ const receipt = this.routines.routineRequestReceipt(requestId);
962
+ if (receipt && receipt.botId === botId && receipt.threadId === threadId && receipt.messageId === message.id &&
963
+ receipt.fingerprintVersion === ROUTINE_REQUEST_FINGERPRINT_VERSION && receipt.fingerprint === routineRequestFingerprint(payload, message.id)) {
964
+ return { ...proposal, result: {
965
+ claimed: true, state: "applied", action: receipt.action, resultId: receipt.resultId,
966
+ settlementPending: true, message: "Routine change applied. Recording the operation receipt could not finish; the change will not be applied again.",
967
+ } };
968
+ }
969
+ throw new RoutineRequestError(result.state === "invalid" ? result.error : "The routine change could not be applied", result.state === "invalid" ? result.status : 409);
887
970
  }
888
971
  async requireCloudReadiness(operation) {
889
972
  if (!this.cloudReady || operation.action === "pause" || operation.action === "delete")
@@ -5,6 +5,7 @@ import { z } from "zod";
5
5
  import { DATA_DIR } from "./config.js";
6
6
  import { writeFileAtomic } from "./atomic.js";
7
7
  import { redactSecretsInText } from "./redact.js";
8
+ import { normalizeCronSchedule, nextCronRuns } from "../shared/routine-schedule.js";
8
9
  const persistedSourceThreadId = z.string().trim().min(1).optional().catch(undefined);
9
10
  const webhookRunReceiptSchema = z.object({
10
11
  webhookId: z.string().min(1).max(200),
@@ -96,6 +97,8 @@ function loadAttachments(value) {
96
97
  function cloneSchedule(schedule) {
97
98
  if (schedule.type === "once")
98
99
  return { type: "once", at: schedule.at };
100
+ if (schedule.type === "cron")
101
+ return { ...schedule };
99
102
  if (schedule.type === "interval") {
100
103
  return {
101
104
  type: "interval",
@@ -236,7 +239,12 @@ function cleanIntervalEndsAt(value, anchorAt) {
236
239
  function finishedOrder(run) {
237
240
  return run.finishedAt ?? run.createdAt;
238
241
  }
239
- function cleanSchedule(schedule) {
242
+ export class RoutineScheduleError extends Error {
243
+ status = 400;
244
+ }
245
+ function parseSchedule(schedule, after) {
246
+ if (schedule?.type === "cron")
247
+ return normalizeCronSchedule(schedule, after);
240
248
  if (schedule?.type === "once") {
241
249
  const at = Number(schedule.at);
242
250
  if (!Number.isFinite(at))
@@ -274,9 +282,17 @@ function cleanSchedule(schedule) {
274
282
  }
275
283
  throw new Error("Choose a supported schedule");
276
284
  }
277
- function loadSchedule(value) {
285
+ function cleanSchedule(schedule, after) {
286
+ try {
287
+ return parseSchedule(schedule, after);
288
+ }
289
+ catch (error) {
290
+ throw new RoutineScheduleError(error.message);
291
+ }
292
+ }
293
+ function loadSchedule(value, after) {
278
294
  try {
279
- return cleanSchedule(value);
295
+ return cleanSchedule(value, after);
280
296
  }
281
297
  catch {
282
298
  return null;
@@ -313,10 +329,12 @@ function nextAlignedInterval(schedule, after) {
313
329
  const candidate = schedule.anchorAt + (intervalsElapsed + 1) * intervalMs;
314
330
  return Number.isSafeInteger(candidate) && candidate <= MAX_DATE_MS ? candidate : null;
315
331
  }
316
- /** Next wall-clock occurrence in this computer's timezone, strictly after `after`. */
332
+ /** Next occurrence strictly after `after`: cron uses its saved zone, daily uses the host zone. */
317
333
  export function nextOccurrence(schedule, after) {
318
334
  if (schedule.type === "once")
319
335
  return schedule.at > after ? schedule.at : null;
336
+ if (schedule.type === "cron")
337
+ return nextCronRuns(schedule, after, 1)[0] ?? null;
320
338
  if (schedule.type === "interval") {
321
339
  const intervalMs = schedule.everyMinutes * 60_000;
322
340
  let candidate = nextAlignedInterval(schedule, after);
@@ -378,7 +396,7 @@ function mergeScheduleUpdate(current, incoming) {
378
396
  }
379
397
  return merged;
380
398
  }
381
- function sanitizeInput(input) {
399
+ function sanitizeInput(input, after) {
382
400
  const name = String(input.name ?? "").trim().slice(0, 80);
383
401
  const prompt = String(input.prompt ?? "").trim().slice(0, 20_000);
384
402
  const botId = String(input.botId ?? "").trim();
@@ -420,7 +438,7 @@ function sanitizeInput(input) {
420
438
  groupId: target === "room-goal" ? groupId : undefined,
421
439
  runOn,
422
440
  enabled: input.enabled !== false,
423
- schedule: cleanSchedule(input.schedule),
441
+ schedule: cleanSchedule(input.schedule, after),
424
442
  durationMinutes: Math.min(240, Math.max(5, Math.round(Number(input.durationMinutes) || 30))),
425
443
  ...(timeoutMinutes === undefined ? {} : { timeoutMinutes }),
426
444
  attachments,
@@ -445,7 +463,7 @@ export class RoutineManager {
445
463
  const disk = JSON.parse(readFileSync(this.file, "utf8"));
446
464
  this.routines = Array.isArray(disk.routines)
447
465
  ? disk.routines.flatMap((routine) => {
448
- const schedule = loadSchedule(routine.schedule);
466
+ const schedule = loadSchedule(routine.schedule, this.now());
449
467
  if (!schedule)
450
468
  return [];
451
469
  const target = loadTarget(routine.target);
@@ -630,10 +648,10 @@ export class RoutineManager {
630
648
  throw new Error("This routine request was already applied");
631
649
  }
632
650
  }
633
- const clean = sanitizeInput(input);
651
+ const at = this.now();
652
+ const clean = sanitizeInput(input, at);
634
653
  if (this.targetState(clean) === "missing")
635
654
  throw new Error(this.missingTargetMessage(clean.target));
636
- const at = this.now();
637
655
  const nextRunAt = clean.enabled ? this.initialOccurrence(clean.schedule, at) : null;
638
656
  if (clean.schedule.type === "interval" && clean.enabled && nextRunAt === null) {
639
657
  throw new Error("This interval has no future runs. Choose a later end date or turn it off.");
@@ -682,7 +700,7 @@ export class RoutineManager {
682
700
  timeoutMinutes: Object.hasOwn(patch, "timeoutMinutes") ? patch.timeoutMinutes : routine.timeoutMinutes,
683
701
  attachments: patch.attachments ?? routine.attachments,
684
702
  continuity: patch.continuity ?? routine.continuity,
685
- });
703
+ }, now);
686
704
  if (this.targetState(clean) === "missing")
687
705
  throw new Error(this.missingTargetMessage(clean.target));
688
706
  const scheduleChanged = JSON.stringify(clean.schedule) !== JSON.stringify(routine.schedule);
@@ -1058,9 +1076,9 @@ export class RoutineManager {
1058
1076
  const scheduledFor = routine.schedule.type === "interval" && late <= CATCH_UP_MS
1059
1077
  ? latestIntervalOccurrence(routine.schedule, now) ?? pendingAt
1060
1078
  : pendingAt;
1061
- // One slow interval run must not build an unbounded queue of stale
1062
- // copies behind it. The series still advances on its original phase.
1063
- const overlapping = routine.schedule.type === "interval" && this.runs.some((run) => run.routineId === routine.id && ["queued", "running", "waiting"].includes(run.status));
1079
+ // Frequent recurring work must not build an unbounded queue of stale
1080
+ // copies. Elapsed intervals keep their phase; cron keeps its calendar.
1081
+ const overlapping = (routine.schedule.type === "interval" || routine.schedule.type === "cron") && this.runs.some((run) => run.routineId === routine.id && ["queued", "running", "waiting"].includes(run.status));
1064
1082
  if (!overlapping) {
1065
1083
  const run = this.newRun(routine, scheduledFor, false, allocations);
1066
1084
  if (late > CATCH_UP_MS) {