openmausbot 0.1.77 → 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 (33) hide show
  1. package/dist/assets/{index-DeK-z9ld.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/drivers/agents-proxy.js +786 -29
  6. package/dist-server/index.js +2750 -1634
  7. package/dist-server/openmausbot.js +5 -1
  8. package/dist-server/pair-cli.js +5 -1
  9. package/dist-server/server/auto-approve.js +16 -0
  10. package/dist-server/server/bot-overview.js +6 -2
  11. package/dist-server/server/bot-package.js +17 -3
  12. package/dist-server/server/chief-of-staff.js +2 -2
  13. package/dist-server/server/drivers/agents-proxy.js +74 -25
  14. package/dist-server/server/index.js +207 -98
  15. package/dist-server/server/package-export.js +16 -14
  16. package/dist-server/server/peer-approval.js +1 -1
  17. package/dist-server/server/profile-requests.js +26 -4
  18. package/dist-server/server/room-handoffs.js +38 -1
  19. package/dist-server/server/routine-requests.js +86 -3
  20. package/dist-server/server/routines.js +31 -13
  21. package/dist-server/server/setup-mode.js +11 -13
  22. package/dist-server/server/skill-learn.js +4 -4
  23. package/dist-server/server/skills.js +21 -0
  24. package/dist-server/server/store.js +90 -1
  25. package/dist-server/server/system-prompt.js +7 -5
  26. package/dist-server/server/team-backup.js +6 -2
  27. package/dist-server/server/team-setup-requests.js +83 -31
  28. package/dist-server/shared/cron-label.js +39 -0
  29. package/dist-server/shared/routine-schedule.js +89 -0
  30. package/dist-server/shared/team-backup.js +1 -1
  31. package/package.json +1 -1
  32. package/dist/assets/index-XdHJ-9CF.css +0 -1
  33. package/dist/assets/index-lhqE1umn.js +0 -305
@@ -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) },
@@ -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) {
@@ -1,15 +1,13 @@
1
1
  // Setup mode: optional coaching when the user asks for it with /setup.
2
2
  // An empty profile is not a request to interview the user. The bot says
3
- // what it intends, and then configures itself only through proposal cards
4
- // (propose_profile, propose_routine, skill_manage, request_credential) — so
5
- // nothing changes without the user's approval. Mirrors skill-learn.ts:
3
+ // what it intends, and then configures itself through the harness tools
4
+ // (propose_profile, propose_routine, skill_manage, request_credential), which
5
+ // enforce the granted approval level. Mirrors skill-learn.ts:
6
6
  // /setup is a turn-text rewrite plus a prompt block, never a hidden mode.
7
7
  //
8
- // Setup mode is card-gated, not provenance-gated: it doesn't matter who sent
9
- // the message that entered it — a peer message or a routine trigger that
10
- // happens to begin with /setup enters it exactly like a message the user
11
- // typed, and nothing about the bot actually changes until a card is
12
- // confirmed. And like /learn, only the current turn's text is rewritten —
8
+ // This coaching block grants no additional authority: the services still
9
+ // check the actual caller, target and effective conversation permissions.
10
+ // Like /learn, only the current turn's text is rewritten —
13
11
  // the transcript keeps the raw "/setup …" user message verbatim, so replay
14
12
  // and history read the same thing the user sent.
15
13
  const SETUP_COMMAND = /^\/setup(?:\s+|$)([\s\S]*)$/i;
@@ -47,11 +45,11 @@ function folderClause(cwd) {
47
45
  function buildSetupPrompt(profileAside, cwd) {
48
46
  return ("\n\nThe user explicitly asked you to set yourself up. For this setup request, help configure the bot from what the user tells you." +
49
47
  ` First ask at most four questions that change what you would build: what the job is, when it should happen (on demand, on a schedule, or when something arrives), which apps or accounts it touches, and ${folderClause(cwd)}.` +
50
- " Then, before any tool call, tell the user in plain language what you intend: who you will be, what you will do and when, where you will work, what you will need from them, and what you will not do. Wait for a yes." +
51
- " When they say yes, first send one message that lists the cards you are about to raise, then make the tool calls — the cards must appear after that message, never before it. After the tool calls add at most one short line and do not repeat the list." +
52
- ` The proposals, each of which the user must confirm: propose_profile for your identity, standing rules ${profileAside}, and the working folder (cwd), propose_routine for anything scheduled (propose it paused), request_credential for any token.` +
53
- " Never claim something is set up until its card is confirmed." +
54
- " Finish by saying exactly what remains for the user to do by hand — authorizing an app or account (OAuth), creating a third-party application or bot token, or enabling a routine — and point them to the Access section of the bot's settings for the app connections.");
48
+ " Then, before any tool call, tell the user in plain language what you intend: who you will be, what you will do and when, where you will work, what you will need from them, and what you will not do. Ask for missing choices, not an extra yes for already-requested actions under granted Full Access." +
49
+ " First send one message describing the changes you are about to request, then make the tool calls. Follow each result: if applied, continue without another confirmation; if pending, end the turn and wait for its in-app decision. Do not repeat the list or claim success from the permission mode alone." +
50
+ ` Use propose_profile for your identity, standing rules ${profileAside}, and the working folder (cwd), propose_routine only for a schedule the user requested, and request_credential for any missing token.` +
51
+ " Full Access does not supply answers, credentials, or broader permissions for another bot. A credential request still needs the user's secure entry. Report failed or cancelled changes honestly." +
52
+ " Finish by saying exactly what remains for the user to do by hand — authorizing an app or account (OAuth), creating a third-party application or bot token, or deciding a pending review — and point them to the Access section of the bot's settings for the app connections.");
55
53
  }
56
54
  /** The setup block naming skill_manage, for a turn with skill authoring on. */
57
55
  export const SETUP_PROMPT = buildSetupPrompt(SKILL_MANAGE_ASIDE);
@@ -1,6 +1,6 @@
1
1
  // `/learn` — turn a described workflow, URL, folder, or "what we just did"
2
- // into a staged SKILL.md. The live agent authors the skill with skill_manage;
3
- // the harness lands it DISABLED until a person confirms the in-app card.
2
+ // into a reusable SKILL.md. The live agent authors the skill with skill_manage;
3
+ // the harness applies it under granted Full Access or stages a pending review.
4
4
  //
5
5
  // There is no separate distillation engine. This module only builds the
6
6
  // prompt and recognises the slash command, so it works on every engine
@@ -56,13 +56,13 @@ Quality bar:
56
56
  export function buildLearnPrompt(userRequest) {
57
57
  const req = userRequest.trim() ||
58
58
  "the workflow we just went through in this conversation — review the steps taken and distill them into a reusable skill";
59
- return (`${LEARN_PROMPT_MARKER} The user wants you to learn a reusable skill from the request below, and stage it for their review.\n\n` +
59
+ return (`${LEARN_PROMPT_MARKER} The user wants you to learn a reusable skill from the request below, using the granted access level.\n\n` +
60
60
  `THE REQUEST:\n${req}\n\n` +
61
61
  "Do this:\n" +
62
62
  "1. Inventory every source the user named, using the tools you already have — file tools for local paths, web fetch for URLs, and this conversation if they referred to something you just did. If the request is ambiguous about scope, make a reasonable choice and note it; do not stall.\n" +
63
63
  "2. Check existing skills with skills_list. If one already covers this topic, leave it alone unless the user explicitly asked to revise that named learned/editable skill. For an explicit revision, read only the exact SKILL.md path listed for that skill in your system prompt (the native .agents/skills/<exact-name>/SKILL.md link is a fallback), preserve every still-valid step, re-verify what changed, then call skill_manage with action=\"update\" and skill_name set to that exact name. If you cannot read or verify the current skill, stop instead of replacing it from memory. For a genuinely new skill, use action=\"create\".\n" +
64
64
  "3. Pass source as the exact URL or folder you used, or \"conversation\" when the conversation is the source.\n" +
65
- "4. skill_manage only STAGES the change. A create stays inactive and an update leaves the current version untouched until the user approves the review card.\n\n" +
65
+ "4. Follow the skill_manage result: with granted Full Access it may apply immediately. After an applied result, continue the requested work without another confirmation. If review is pending, a create stays inactive and an update leaves the current version untouched; end the turn and wait for the in-app decision. Never claim success from the permission mode alone, and report failed or cancelled changes honestly.\n\n" +
66
66
  AUTHORING_STANDARDS +
67
67
  "\n\nWhen done, tell the user the skill name and a one-line summary of what it captured.");
68
68
  }
@@ -1191,6 +1191,27 @@ export function applyStagedSkillWrite(botId, id, options = {}) {
1191
1191
  writeStaged(botId, store);
1192
1192
  return installed;
1193
1193
  }
1194
+ /** Full Access uses the same exact-content apply path. Once installed, a
1195
+ * receipt or staging-cleanup failure must not tell the caller to apply again. */
1196
+ export function applySkillWriteWithReceipt(botId, staged, recordApplied) {
1197
+ let installed;
1198
+ try {
1199
+ const result = applyStagedSkillWrite(botId, staged.id, {
1200
+ expectedSha256: staged.sha256,
1201
+ onApplied: (skill) => {
1202
+ installed = skill;
1203
+ recordApplied(skill);
1204
+ },
1205
+ });
1206
+ return "error" in result ? result : { result };
1207
+ }
1208
+ catch (error) {
1209
+ if (!installed)
1210
+ throw error;
1211
+ return { result: installed, settlementPending: true,
1212
+ message: "Skill change applied. Recording its receipt or cleaning up staging could not finish; do not apply it again." };
1213
+ }
1214
+ }
1194
1215
  /** The skills block appended to a bot's system prompt: enabled skills only,
1195
1216
  * index lines only — the same progressive-disclosure shape the spec asks
1196
1217
  * agents for. Bodies never ride the prompt; the bot reads the file when a