loopengine 0.1.93 → 0.1.95

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.
package/README.md CHANGED
@@ -253,7 +253,12 @@ The server runs one scheduler (`core/scheduler.ts`) that sends a message
253
253
  to an agent later — once (`run_at`) or on an interval (`every: "1h"`),
254
254
  optionally from a set first run (`start_at`), which every later run keeps:
255
255
  `every: "1d"` from a 9:00 start runs daily at 9:00, `every: "7d"` from a
256
- Monday runs on Mondays — from the console's **Schedules** tab, the `GET/POST
256
+ Monday runs on Mondays. `at: "09:00"` (plus `weekday: "monday"`) picks
257
+ that start for you. Times of day are in the workspace's time zone, chosen
258
+ in the console's **Settings** (`LOOPENGINE_TIME_ZONE`; the server's own
259
+ when unset): a timestamp without an offset is read there, and daily and
260
+ weekly tasks keep their time of day when the clocks change. Schedule
261
+ from the console's **Schedules** tab, the `GET/POST
257
262
  /agents/<name>/schedules` API, or the agent itself: every agent has
258
263
  built-in `system_schedule_task`, `system_list_scheduled_tasks`,
259
264
  `system_check_scheduled_task` and `system_cancel_scheduled_task` tools,
@@ -1,8 +1,12 @@
1
+ /** `time_zone` (repeating in whole days only): the zone whose calendar
2
+ * days it steps through, so its time of day survives daylight saving.
3
+ * Tasks made before it existed step in exact 24-hour days. */
1
4
  export type Schedule = {
2
5
  run_at: string;
3
6
  } | {
4
7
  every: string;
5
8
  anchor?: string;
9
+ time_zone?: string;
6
10
  };
7
11
  export type ScheduledTaskStatus = 'scheduled' | 'running' | 'awaiting_approval' | 'paused' | 'done' | 'cancelled';
8
12
  export interface ScheduledTaskRun {
@@ -77,11 +81,16 @@ export interface CreateScheduledTaskInput {
77
81
  * time of day / day of week every later run keeps, e.g. every "1d"
78
82
  * from "2026-11-02T09:00:00+08:00" runs daily at 9:00. A start in the
79
83
  * past is fine: the first run is its next slot after now. Without it,
80
- * the first run is one interval from now. */
84
+ * the first run is one interval from now. `at` ("09:00", with `every`
85
+ * only) instead starts it at the next 9:00 in the workspace's time
86
+ * zone, and `weekday` ("monday", or 0 for Sunday to 6) at the next one
87
+ * on that day. */
81
88
  schedule: {
82
89
  run_at?: unknown;
83
90
  every?: unknown;
84
91
  start_at?: unknown;
92
+ at?: unknown;
93
+ weekday?: unknown;
85
94
  };
86
95
  task_name?: string;
87
96
  notify?: boolean;
@@ -101,6 +110,13 @@ export declare function resumeScheduledTask(taskId: string, nowMs?: number): Pro
101
110
  * finished one-off task runs once more. */
102
111
  export declare function runScheduledTaskNow(taskId: string, nowMs?: number): Promise<ScheduledTask>;
103
112
  export declare function deleteScheduledTask(taskId: string): Promise<void>;
113
+ /** Moves every daily and weekly (whole-day) task to `toTimeZone`,
114
+ * keeping its time of day: a 9:00 task made while the workspace was on
115
+ * UTC runs at 9:00 in the new zone. A task from before tasks kept their
116
+ * zone is taken to be in `fromTimeZone`. One-off and shorter repeating
117
+ * tasks are left alone: they're a moment, not a time of day. Returns
118
+ * how many moved. */
119
+ export declare function moveScheduledTasksToTimeZone(fromTimeZone: string, toTimeZone: string, nowMs?: number): Promise<number>;
104
120
  /** When the next task is due — for a host that stops idle servers and
105
121
  * needs to wake this one in time (GET /internal/activity). */
106
122
  export declare function nextScheduledWakeAt(): Promise<string | undefined>;
@@ -12,15 +12,19 @@
12
12
  // `every` is anchored: an hourly task created at 10:07 keeps firing at
13
13
  // :07, however long each run takes. `start_at` sets the anchor instead,
14
14
  // for "daily at 9:00" ("1d" from a 9:00 start) or "Mondays at 9:00" ("7d"
15
- // from a Monday). Intervals are exact durations, so in a time zone with
16
- // daylight saving a daily 9:00 task runs at 8:00 or 10:00 local time
17
- // after the clocks change. A run missed while the server was down fires
15
+ // from a Monday), or `at` ("09:00", plus a `weekday` for "7d") works the
16
+ // start out in the workspace's time zone (core/time-zone.ts). Intervals
17
+ // in days are calendar days in the time zone the task was made in, so a
18
+ // daily 9:00 task stays at 9:00 when the clocks change; shorter ones are
19
+ // exact durations. Timestamps without an offset are read in the
20
+ // workspace's time zone too. A run missed while the server was down fires
18
21
  // once when it's back, then the schedule moves to the next future slot
19
22
  // (no burst of catch-up runs).
20
23
  import { randomUUID } from 'node:crypto';
21
24
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
22
25
  import path from 'node:path';
23
26
  import { KeyedMutex } from './session-store.js';
27
+ import { addCalendarDays, instantOf, nextTimeOfDay, parseTimeOfDay, parseTimestamp, wallTimeAt, workspaceTimeZone } from './time-zone.js';
24
28
  const HISTORY_LIMIT = 20;
25
29
  const EVERY_PATTERN = /^(\d+)\s*(s|m|h|d)$/;
26
30
  const EVERY_UNIT_MS = { s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 };
@@ -37,11 +41,38 @@ export function parseEvery(every) {
37
41
  throw new ScheduleError(`"${every}" is too short — the shortest interval is 30s`);
38
42
  return ms;
39
43
  }
40
- /** The first anchored slot strictly after `nowMs`. */
41
- function nextAnchoredSlot(anchorMs, intervalMs, nowMs) {
44
+ /** Whole days in an interval ("2d" → 2), or undefined for one in s/m/h. */
45
+ function everyDays(every) {
46
+ const match = EVERY_PATTERN.exec(every.trim());
47
+ return match && match[2] === 'd' ? Number(match[1]) : undefined;
48
+ }
49
+ /** The first anchored slot strictly after `nowMs`: exact steps of
50
+ * `intervalMs`, or with `days` and `timeZone`, steps of that many
51
+ * calendar days there. */
52
+ function nextAnchoredSlot(anchorMs, intervalMs, nowMs, calendar) {
42
53
  if (nowMs < anchorMs)
43
54
  return anchorMs;
44
- return anchorMs + (Math.floor((nowMs - anchorMs) / intervalMs) + 1) * intervalMs;
55
+ let k = Math.floor((nowMs - anchorMs) / intervalMs) + 1;
56
+ if (!calendar)
57
+ return anchorMs + k * intervalMs;
58
+ // A day is 23 to 25 hours, so the estimate can be one step off.
59
+ const slot = (n) => addCalendarDays(anchorMs, n * calendar.days, calendar.timeZone);
60
+ while (k > 1 && slot(k - 1) > nowMs)
61
+ k--;
62
+ while (slot(k) <= nowMs)
63
+ k++;
64
+ return slot(k);
65
+ }
66
+ const WEEKDAY_NAMES = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
67
+ function parseWeekday(value) {
68
+ if (typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 6)
69
+ return value;
70
+ if (typeof value === 'string') {
71
+ const i = WEEKDAY_NAMES.findIndex((d) => d.startsWith(value.trim().toLowerCase()) && value.trim().length >= 3);
72
+ if (i >= 0)
73
+ return i;
74
+ }
75
+ throw new ScheduleError(`weekday "${String(value)}" isn't a day: use monday to sunday, or 0 (Sunday) to 6`);
45
76
  }
46
77
  /** Human-readable schedule, for lists and messages. */
47
78
  export function describeSchedule(schedule) {
@@ -114,34 +145,53 @@ export async function createScheduledTask(input, nowMs = Date.now()) {
114
145
  throw new ScheduleError('message is required');
115
146
  let schedule;
116
147
  let nextRunMs;
148
+ const timeZone = workspaceTimeZone();
117
149
  if (typeof input.schedule?.run_at === 'string' && input.schedule.run_at) {
118
- const runAtMs = Date.parse(input.schedule.run_at);
150
+ const runAtMs = parseTimestamp(input.schedule.run_at, timeZone);
119
151
  if (Number.isNaN(runAtMs))
120
152
  throw new ScheduleError(`"${input.schedule.run_at}" is not a valid ISO 8601 timestamp`);
121
153
  schedule = { run_at: new Date(runAtMs).toISOString() };
122
154
  nextRunMs = runAtMs;
123
155
  }
124
156
  else if (typeof input.schedule?.every === 'string' && input.schedule.every) {
125
- const intervalMs = parseEvery(input.schedule.every);
126
- const startAt = input.schedule.start_at;
127
- if (startAt !== undefined && startAt !== null && startAt !== '') {
128
- const startMs = typeof startAt === 'string' ? Date.parse(startAt) : Number.NaN;
157
+ const every = input.schedule.every.trim();
158
+ const intervalMs = parseEvery(every);
159
+ const days = everyDays(every);
160
+ const calendar = days ? { days, timeZone } : undefined;
161
+ const { start_at: startAt, at, weekday } = input.schedule;
162
+ const given = (v) => v !== undefined && v !== null && v !== '';
163
+ let startMs;
164
+ if (given(at)) {
165
+ if (given(startAt))
166
+ throw new ScheduleError('give either start_at or at, not both');
167
+ const time = typeof at === 'string' ? parseTimeOfDay(at) : undefined;
168
+ if (!time)
169
+ throw new ScheduleError(`at "${String(at)}" isn't a time of day: use 24-hour "HH:MM", e.g. "09:00"`);
170
+ startMs = nextTimeOfDay(time, nowMs, timeZone, given(weekday) ? parseWeekday(weekday) : undefined);
171
+ }
172
+ else if (given(weekday)) {
173
+ throw new ScheduleError('weekday goes with at, e.g. { "every": "7d", "at": "09:00", "weekday": "monday" }');
174
+ }
175
+ else if (given(startAt)) {
176
+ startMs = typeof startAt === 'string' ? parseTimestamp(startAt, timeZone) : Number.NaN;
129
177
  if (Number.isNaN(startMs))
130
178
  throw new ScheduleError(`start_at "${String(startAt)}" is not a valid ISO 8601 timestamp`);
179
+ }
180
+ if (startMs !== undefined) {
131
181
  // The anchor is the start itself, so every run lands on its time of
132
182
  // day (or weekday, for "7d").
133
- nextRunMs = nextAnchoredSlot(startMs, intervalMs, nowMs);
134
- schedule = { every: input.schedule.every.trim(), anchor: new Date(startMs).toISOString() };
183
+ nextRunMs = nextAnchoredSlot(startMs, intervalMs, nowMs, calendar);
184
+ schedule = { every, anchor: new Date(startMs).toISOString(), ...(calendar ? { time_zone: timeZone } : {}) };
135
185
  }
136
186
  else {
137
- nextRunMs = nowMs + intervalMs;
138
- schedule = { every: input.schedule.every.trim(), anchor: new Date(nextRunMs).toISOString() };
187
+ nextRunMs = calendar ? addCalendarDays(nowMs, calendar.days, timeZone) : nowMs + intervalMs;
188
+ schedule = { every, anchor: new Date(nextRunMs).toISOString(), ...(calendar ? { time_zone: timeZone } : {}) };
139
189
  }
140
190
  }
141
191
  else {
142
- if (input.schedule?.start_at)
143
- throw new ScheduleError('start_at goes with "every" (a repeating task); a one-off task uses run_at');
144
- throw new ScheduleError('schedule must be { "run_at": "<ISO timestamp>" } or { "every": "<interval, e.g. 1h>", "start_at"?: "<ISO timestamp>" }');
192
+ if (input.schedule?.start_at || input.schedule?.at)
193
+ throw new ScheduleError('start_at and at go with "every" (a repeating task); a one-off task uses run_at');
194
+ throw new ScheduleError('schedule must be { "run_at": "<ISO timestamp>" } or { "every": "<interval, e.g. 1h>", "start_at"?: "<ISO timestamp>" } or { "every": "1d", "at": "09:00" }');
145
195
  }
146
196
  const task = {
147
197
  task_id: randomUUID(),
@@ -197,6 +247,36 @@ export async function runScheduledTaskNow(taskId, nowMs = Date.now()) {
197
247
  export async function deleteScheduledTask(taskId) {
198
248
  await getSchedulerStore().remove(taskId);
199
249
  }
250
+ /** Moves every daily and weekly (whole-day) task to `toTimeZone`,
251
+ * keeping its time of day: a 9:00 task made while the workspace was on
252
+ * UTC runs at 9:00 in the new zone. A task from before tasks kept their
253
+ * zone is taken to be in `fromTimeZone`. One-off and shorter repeating
254
+ * tasks are left alone: they're a moment, not a time of day. Returns
255
+ * how many moved. */
256
+ export async function moveScheduledTasksToTimeZone(fromTimeZone, toTimeZone, nowMs = Date.now()) {
257
+ let moved = 0;
258
+ for (const listed of await getSchedulerStore().list()) {
259
+ if (listed.status === 'done' || listed.status === 'cancelled' || !('every' in listed.schedule) || !everyDays(listed.schedule.every))
260
+ continue;
261
+ await getSchedulerStore().update(listed.task_id, (t) => {
262
+ if (!t || !('every' in t.schedule) || !t.schedule.anchor)
263
+ return t;
264
+ const days = everyDays(t.schedule.every);
265
+ const from = t.schedule.time_zone ?? fromTimeZone;
266
+ if (!days || from === toTimeZone)
267
+ return t;
268
+ const anchorMs = instantOf(wallTimeAt(Date.parse(t.schedule.anchor), from), toTimeZone);
269
+ const schedule = { ...t.schedule, anchor: new Date(anchorMs).toISOString(), time_zone: toTimeZone };
270
+ moved++;
271
+ // A run that's due or under way keeps its slot; the next one moves.
272
+ if (t.status !== 'scheduled' || !t.next_run_at || Date.parse(t.next_run_at) <= nowMs)
273
+ return { ...t, schedule };
274
+ const next = nextAnchoredSlot(anchorMs, parseEvery(t.schedule.every), nowMs, { days, timeZone: toTimeZone });
275
+ return { ...t, schedule, next_run_at: new Date(next).toISOString() };
276
+ });
277
+ }
278
+ return moved;
279
+ }
200
280
  /** When the next task is due — for a host that stops idle servers and
201
281
  * needs to wake this one in time (GET /internal/activity). */
202
282
  export async function nextScheduledWakeAt() {
@@ -208,7 +288,8 @@ function nextRunAfter(task, nowMs) {
208
288
  return undefined;
209
289
  const intervalMs = parseEvery(task.schedule.every);
210
290
  const anchorMs = task.schedule.anchor ? Date.parse(task.schedule.anchor) : Date.parse(task.created_at) + intervalMs;
211
- return nextAnchoredSlot(anchorMs, intervalMs, nowMs);
291
+ const days = everyDays(task.schedule.every);
292
+ return nextAnchoredSlot(anchorMs, intervalMs, nowMs, days && task.schedule.time_zone ? { days, timeZone: task.schedule.time_zone } : undefined);
212
293
  }
213
294
  /** Moves the old lp-task-scheduler's task files (`<dir>/.tasks/*.json`)
214
295
  * into this store once, renaming each to `.json.migrated`. Returns how
@@ -13,7 +13,7 @@ export interface TemplateSkill {
13
13
  export interface TemplateSchedule {
14
14
  /** How often, in the scheduler's terms: "10m", "1h", "7d". */
15
15
  every: string;
16
- /** Optional first run, as a time of day in the server's time zone
16
+ /** Optional first run, as a time of day in the workspace's time zone
17
17
  * ("09:00"), and for weekly schedules a weekday (1 = Monday). Without
18
18
  * it, the first run is one interval after the schedule is turned on. */
19
19
  at?: string;
@@ -49,5 +49,5 @@ export interface StarterTemplate {
49
49
  export declare const STARTER_TEMPLATES: readonly StarterTemplate[];
50
50
  export declare function starterTemplate(id: string): StarterTemplate | undefined;
51
51
  /** The first run of a template's schedule: its time of day (and weekday)
52
- * next, in the server's time zone; undefined when it has none. */
53
- export declare function templateScheduleStart(schedule: TemplateSchedule, nowMs?: number): string | undefined;
52
+ * next, in the workspace's time zone; undefined when it has none. */
53
+ export declare function templateScheduleStart(schedule: TemplateSchedule, nowMs?: number, timeZone?: string): string | undefined;
@@ -1,3 +1,4 @@
1
+ import { nextTimeOfDay, parseTimeOfDay, workspaceTimeZone } from '../time-zone.js';
1
2
  const GMAIL_KEYS = ['GMAIL_CLIENT_ID', 'GMAIL_CLIENT_SECRET', 'GMAIL_REFRESH_TOKEN'];
2
3
  const SHOPIFY_KEYS = ['SHOPIFY_STORE_DOMAIN', 'SHOPIFY_ADMIN_TOKEN'];
3
4
  const GITHUB_KEYS = ['GITHUB_TOKEN', 'GITHUB_DEFAULT_REPO'];
@@ -378,19 +379,10 @@ export function starterTemplate(id) {
378
379
  return STARTER_TEMPLATES.find((t) => t.id === id);
379
380
  }
380
381
  /** The first run of a template's schedule: its time of day (and weekday)
381
- * next, in the server's time zone; undefined when it has none. */
382
- export function templateScheduleStart(schedule, nowMs = Date.now()) {
383
- if (!schedule.at)
382
+ * next, in the workspace's time zone; undefined when it has none. */
383
+ export function templateScheduleStart(schedule, nowMs = Date.now(), timeZone = workspaceTimeZone()) {
384
+ const at = schedule.at ? parseTimeOfDay(schedule.at) : undefined;
385
+ if (!at)
384
386
  return undefined;
385
- const [hours, minutes] = schedule.at.split(':').map(Number);
386
- const start = new Date(nowMs);
387
- start.setHours(hours, minutes, 0, 0);
388
- if (schedule.weekday !== undefined) {
389
- const ahead = (schedule.weekday - start.getDay() + 7) % 7;
390
- start.setDate(start.getDate() + ahead);
391
- }
392
- const step = schedule.weekday !== undefined ? 7 : 1;
393
- while (start.getTime() <= nowMs)
394
- start.setDate(start.getDate() + step);
395
- return start.toISOString();
387
+ return new Date(nextTimeOfDay(at, nowMs, timeZone, schedule.weekday)).toISOString();
396
388
  }
@@ -1,4 +1,5 @@
1
1
  import { cancelScheduledTask, createScheduledTask, getScheduledTask, listScheduledTasks, } from '../scheduler.js';
2
+ import { formatWallTime, workspaceTimeZone } from '../time-zone.js';
2
3
  /** How many active (not done or cancelled) tasks one agent may create
3
4
  * through these tools — each run is a model call, so a runaway loop of
4
5
  * "schedule another one" stops here. The console and API aren't capped. */
@@ -6,7 +7,12 @@ function maxActivePerAgent() {
6
7
  const n = Number(process.env.LOOPENGINE_SCHEDULER_MAX_PER_AGENT);
7
8
  return Number.isInteger(n) && n > 0 ? n : 25;
8
9
  }
9
- function summary(t) {
10
+ /** next_run_at as the workspace's wall-clock time, so the model can
11
+ * confirm "tomorrow at 9:00" without converting from UTC. */
12
+ function nextRunLocal(t, timeZone) {
13
+ return t.next_run_at ? `${formatWallTime(Date.parse(t.next_run_at), timeZone)} (${timeZone})` : undefined;
14
+ }
15
+ function summary(t, timeZone) {
10
16
  return {
11
17
  task_id: t.task_id,
12
18
  task_name: t.task_name,
@@ -14,11 +20,15 @@ function summary(t) {
14
20
  schedule: t.schedule,
15
21
  status: t.status,
16
22
  next_run_at: t.next_run_at,
23
+ next_run_local: nextRunLocal(t, timeZone),
17
24
  run_count: t.run_count,
18
25
  last_status: t.last_status,
19
26
  };
20
27
  }
21
28
  export function createScheduleTools(agentName) {
29
+ // Read once per run: the time zone is in the tool description, which
30
+ // should stay the same from call to call.
31
+ const timeZone = workspaceTimeZone();
22
32
  async function ownTask(taskId, tool) {
23
33
  const task = await getScheduledTask(taskId);
24
34
  // Another agent's task reads exactly like a missing one.
@@ -28,7 +38,7 @@ export function createScheduleTools(agentName) {
28
38
  }
29
39
  const scheduleTask = {
30
40
  name: 'system_schedule_task',
31
- description: 'Schedule a message to yourself for later — once at a set time, or repeatedly on an interval — like setting a reminder or a cron job. When it fires you receive the message as a new conversation (none of the current one carries over, so make the message self-contained) and handle it with your tools as usual, with no person live. A tool call that needs approval goes wherever your approval requests are sent and waits; if nowhere is set, it is denied. Use only when the user asks for something to happen later or regularly, never on your own initiative. Use the user\'s time zone (ask if unclear), prefer the longest interval that serves them since each run is a model call, and confirm the schedule back in plain words. Returns task_id and next_run_at.',
41
+ description: 'Schedule a message to yourself for later — once at a set time, or repeatedly on an interval — like setting a reminder or a cron job. When it fires you receive the message as a new conversation (none of the current one carries over, so make the message self-contained) and handle it with your tools as usual, with no person live. A tool call that needs approval goes wherever your approval requests are sent and waits; if nowhere is set, it is denied. Use only when the user asks for something to happen later or regularly, never on your own initiative. Times are in this workspace\'s time zone, ' + timeZone + ', unless you give an offset, so when the user means another time zone, give its offset. Prefer the longest interval that serves them since each run is a model call, and confirm the schedule back in plain words. Returns task_id, next_run_at and next_run_local (the workspace\'s time).',
32
42
  input_schema: {
33
43
  type: 'object',
34
44
  properties: {
@@ -38,8 +48,8 @@ export function createScheduleTools(agentName) {
38
48
  },
39
49
  schedule: {
40
50
  type: 'object',
41
- description: 'Either { "run_at": "<ISO 8601 timestamp with time zone>" } for once, or { "every": "<interval>", "start_at"?: "<ISO 8601 timestamp with time zone>" } to repeat until cancelled — a number plus s/m/h/d, e.g. "30m", "1h", "1d"; 30s at the shortest. A repeating task keeps its first slot: started at 10:07 with "1h", it fires at 11:07, 12:07, ... Use start_at to pick that first slot, for a time of day or day of week: "every day at 9am" is every "1d" with start_at the next 9:00 in the user\'s time zone; "every Monday at 9am" is every "7d" with start_at the next Monday 9:00.',
42
- properties: { run_at: { type: 'string' }, every: { type: 'string' }, start_at: { type: 'string' } },
51
+ description: 'Either { "run_at": "<ISO 8601 timestamp>" } for once, or { "every": "<interval>", ... } to repeat until cancelled — a number plus s/m/h/d, e.g. "30m", "1h", "1d"; 30s at the shortest. A timestamp without an offset, like "2026-11-02T09:00", is in the workspace\'s time zone (' + timeZone + '). A repeating task keeps its first slot: started at 10:07 with "1h", it fires at 11:07, 12:07, ... For a time of day or day of week, add "at" (24-hour "HH:MM") and for weekly "weekday": "every day at 9am" is { "every": "1d", "at": "09:00" }; "every Monday at 9am" is { "every": "7d", "at": "09:00", "weekday": "monday" }. Daily and weekly tasks keep their time of day when the clocks change. Or give "start_at", a timestamp, for the first run.',
52
+ properties: { run_at: { type: 'string' }, every: { type: 'string' }, at: { type: 'string' }, weekday: { type: 'string' }, start_at: { type: 'string' } },
43
53
  },
44
54
  task_name: { type: 'string', description: 'Optional short label, shown when listing tasks and in the console.' },
45
55
  notify: { type: 'boolean', description: 'Optional — also send each run\'s result through your notifier (where your approval requests go), so a person sees it.' },
@@ -58,7 +68,7 @@ export function createScheduleTools(agentName) {
58
68
  task_name: typeof input.task_name === 'string' ? input.task_name : undefined,
59
69
  notify: input.notify === true,
60
70
  });
61
- return JSON.stringify({ task_id: task.task_id, next_run_at: task.next_run_at, schedule: task.schedule });
71
+ return JSON.stringify({ task_id: task.task_id, next_run_at: task.next_run_at, next_run_local: nextRunLocal(task, timeZone), schedule: task.schedule });
62
72
  },
63
73
  };
64
74
  const listTasks = {
@@ -76,7 +86,7 @@ export function createScheduleTools(agentName) {
76
86
  },
77
87
  execute: async (input) => {
78
88
  const status = typeof input.status === 'string' ? input.status : undefined;
79
- return JSON.stringify((await listScheduledTasks({ agent: agentName, status })).map(summary));
89
+ return JSON.stringify((await listScheduledTasks({ agent: agentName, status })).map((t) => summary(t, timeZone)));
80
90
  },
81
91
  safe: true,
82
92
  };
@@ -0,0 +1,40 @@
1
+ export declare const TIME_ZONE_ENV = "LOOPENGINE_TIME_ZONE";
2
+ export interface WallTime {
3
+ year: number;
4
+ /** 1–12 */
5
+ month: number;
6
+ day: number;
7
+ hour: number;
8
+ minute: number;
9
+ second: number;
10
+ }
11
+ export declare function isValidTimeZone(timeZone: unknown): timeZone is string;
12
+ /** The server's own time zone. */
13
+ export declare function serverTimeZone(): string;
14
+ /** The workspace's time zone: LOOPENGINE_TIME_ZONE when it's a real one,
15
+ * else the server's. */
16
+ export declare function workspaceTimeZone(): string;
17
+ /** Every time zone this server knows, for the console's picker. */
18
+ export declare function listTimeZones(): string[];
19
+ /** The wall-clock time (and weekday, 0 = Sunday) at `ms` in `timeZone`. */
20
+ export declare function wallTimeAt(ms: number, timeZone: string): WallTime & {
21
+ weekday: number;
22
+ };
23
+ /** The instant a wall-clock time in `timeZone` happens. Fields may
24
+ * overflow (day 32, hour 25), as with Date.UTC. */
25
+ export declare function instantOf(t: WallTime, timeZone: string): number;
26
+ /** An ISO 8601 timestamp as ms. One with a "Z" or an offset is exact;
27
+ * one without ("2026-11-02T09:00") is a wall-clock time in `timeZone`.
28
+ * NaN when it isn't a timestamp. */
29
+ export declare function parseTimestamp(value: string, timeZone: string): number;
30
+ /** "09:00" as [hours, minutes], or undefined when it isn't a time. */
31
+ export declare function parseTimeOfDay(value: string): [number, number] | undefined;
32
+ /** The next time it's `at` ("09:00") in `timeZone`, strictly after
33
+ * `nowMs`, on `weekday` (0 = Sunday) when given. */
34
+ export declare function nextTimeOfDay(at: [number, number], nowMs: number, timeZone: string, weekday?: number): number;
35
+ /** `ms` moved by whole calendar days in `timeZone`, keeping its time of
36
+ * day across a daylight-saving change. */
37
+ export declare function addCalendarDays(ms: number, days: number, timeZone: string): number;
38
+ /** `ms` as "2026-11-02 09:00" in `timeZone`, for tool results the model
39
+ * reads back to a person. */
40
+ export declare function formatWallTime(ms: number, timeZone: string): string;
@@ -0,0 +1,138 @@
1
+ // The workspace's time zone: what "9:00" means in a schedule, a
2
+ // template's start time and the console's date fields. Set from the
3
+ // console's Settings (LOOPENGINE_TIME_ZONE, an IANA name such as
4
+ // "America/New_York"); unset, it's the server's own, which on a hosted
5
+ // workspace is UTC.
6
+ //
7
+ // Times are worked out with Intl alone, no time zone data of our own:
8
+ // a wall-clock time is turned into an instant by guessing its offset
9
+ // and correcting it twice, which settles on the right answer either side
10
+ // of a daylight-saving change. A time the clocks skip (02:30 on the
11
+ // morning they spring forward) lands an hour later; a time that happens
12
+ // twice (the hour they fall back) is the first of the two.
13
+ export const TIME_ZONE_ENV = 'LOOPENGINE_TIME_ZONE';
14
+ const formatters = new Map();
15
+ function formatter(timeZone) {
16
+ let f = formatters.get(timeZone);
17
+ if (!f) {
18
+ f = new Intl.DateTimeFormat('en-US', {
19
+ timeZone,
20
+ hourCycle: 'h23',
21
+ year: 'numeric',
22
+ month: 'numeric',
23
+ day: 'numeric',
24
+ hour: 'numeric',
25
+ minute: 'numeric',
26
+ second: 'numeric',
27
+ weekday: 'short',
28
+ });
29
+ formatters.set(timeZone, f);
30
+ }
31
+ return f;
32
+ }
33
+ export function isValidTimeZone(timeZone) {
34
+ if (typeof timeZone !== 'string' || !timeZone.trim())
35
+ return false;
36
+ try {
37
+ new Intl.DateTimeFormat('en-US', { timeZone });
38
+ return true;
39
+ }
40
+ catch {
41
+ return false;
42
+ }
43
+ }
44
+ /** The server's own time zone. */
45
+ export function serverTimeZone() {
46
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
47
+ }
48
+ /** The workspace's time zone: LOOPENGINE_TIME_ZONE when it's a real one,
49
+ * else the server's. */
50
+ export function workspaceTimeZone() {
51
+ const configured = process.env[TIME_ZONE_ENV]?.trim();
52
+ return configured && isValidTimeZone(configured) ? configured : serverTimeZone();
53
+ }
54
+ /** Every time zone this server knows, for the console's picker. */
55
+ export function listTimeZones() {
56
+ const supported = Intl.supportedValuesOf?.('timeZone') ?? [];
57
+ return supported.includes('UTC') ? supported : ['UTC', ...supported];
58
+ }
59
+ const WEEKDAY_INDEX = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
60
+ /** The wall-clock time (and weekday, 0 = Sunday) at `ms` in `timeZone`. */
61
+ export function wallTimeAt(ms, timeZone) {
62
+ const parts = {};
63
+ for (const p of formatter(timeZone).formatToParts(new Date(ms)))
64
+ parts[p.type] = p.value;
65
+ return {
66
+ year: Number(parts.year),
67
+ month: Number(parts.month),
68
+ day: Number(parts.day),
69
+ hour: Number(parts.hour),
70
+ minute: Number(parts.minute),
71
+ second: Number(parts.second),
72
+ weekday: WEEKDAY_INDEX[parts.weekday] ?? 0,
73
+ };
74
+ }
75
+ function asUtcMs(t) {
76
+ return Date.UTC(t.year, t.month - 1, t.day, t.hour, t.minute, t.second);
77
+ }
78
+ /** How far ahead of UTC `timeZone` is at `ms`, in ms. */
79
+ function offsetAt(ms, timeZone) {
80
+ return asUtcMs(wallTimeAt(Math.floor(ms / 1000) * 1000, timeZone)) - Math.floor(ms / 1000) * 1000;
81
+ }
82
+ /** The instant a wall-clock time in `timeZone` happens. Fields may
83
+ * overflow (day 32, hour 25), as with Date.UTC. */
84
+ export function instantOf(t, timeZone) {
85
+ const naive = asUtcMs(t);
86
+ const first = naive - offsetAt(naive, timeZone);
87
+ const second = naive - offsetAt(first, timeZone);
88
+ // Either side of a change the two guesses differ; the earlier one is
89
+ // right for a repeated hour, the later one for a skipped hour.
90
+ if (first === second)
91
+ return first;
92
+ return offsetAt(Math.min(first, second), timeZone) === naive - Math.min(first, second) ? Math.min(first, second) : Math.max(first, second);
93
+ }
94
+ const LOCAL_TIMESTAMP = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?$/;
95
+ /** An ISO 8601 timestamp as ms. One with a "Z" or an offset is exact;
96
+ * one without ("2026-11-02T09:00") is a wall-clock time in `timeZone`.
97
+ * NaN when it isn't a timestamp. */
98
+ export function parseTimestamp(value, timeZone) {
99
+ const local = LOCAL_TIMESTAMP.exec(value.trim());
100
+ if (!local)
101
+ return Date.parse(value);
102
+ const [, y, mo, d, h, mi, s] = local;
103
+ const t = { year: Number(y), month: Number(mo), day: Number(d), hour: Number(h), minute: Number(mi), second: Number(s ?? 0) };
104
+ if (t.month < 1 || t.month > 12 || t.day < 1 || t.day > 31 || t.hour > 23 || t.minute > 59 || t.second > 59)
105
+ return Number.NaN;
106
+ return instantOf(t, timeZone);
107
+ }
108
+ const TIME_OF_DAY = /^([01]?\d|2[0-3]):([0-5]\d)$/;
109
+ /** "09:00" as [hours, minutes], or undefined when it isn't a time. */
110
+ export function parseTimeOfDay(value) {
111
+ const match = TIME_OF_DAY.exec(value.trim());
112
+ return match ? [Number(match[1]), Number(match[2])] : undefined;
113
+ }
114
+ /** The next time it's `at` ("09:00") in `timeZone`, strictly after
115
+ * `nowMs`, on `weekday` (0 = Sunday) when given. */
116
+ export function nextTimeOfDay(at, nowMs, timeZone, weekday) {
117
+ const today = wallTimeAt(nowMs, timeZone);
118
+ const ahead = weekday === undefined ? 0 : (weekday - today.weekday + 7) % 7;
119
+ const step = weekday === undefined ? 1 : 7;
120
+ for (let days = ahead;; days += step) {
121
+ const ms = instantOf({ year: today.year, month: today.month, day: today.day + days, hour: at[0], minute: at[1], second: 0 }, timeZone);
122
+ if (ms > nowMs)
123
+ return ms;
124
+ }
125
+ }
126
+ /** `ms` moved by whole calendar days in `timeZone`, keeping its time of
127
+ * day across a daylight-saving change. */
128
+ export function addCalendarDays(ms, days, timeZone) {
129
+ const t = wallTimeAt(ms, timeZone);
130
+ return instantOf({ ...t, day: t.day + days }, timeZone);
131
+ }
132
+ /** `ms` as "2026-11-02 09:00" in `timeZone`, for tool results the model
133
+ * reads back to a person. */
134
+ export function formatWallTime(ms, timeZone) {
135
+ const t = wallTimeAt(ms, timeZone);
136
+ const pad = (n) => String(n).padStart(2, '0');
137
+ return `${t.year}-${pad(t.month)}-${pad(t.day)} ${pad(t.hour)}:${pad(t.minute)}`;
138
+ }
package/dist/index.d.ts CHANGED
@@ -10,7 +10,7 @@ export { setUsageSink, recordUsage, type UsageEvent, type UsageSink } from '#cor
10
10
  export { readNotifierFile, writeNotifierFile, removeNotifierFile, validateNotifierFile, notifierConfigFromFile, NotifierFileError, NOTIFIER_SECRET_VARS, type NotifierFile, type MessagingApprover } from '#core/notifier-file.js';
11
11
  export { listDeliveries, recordDelivery, type NotifierDelivery } from '#core/notifier-deliveries.js';
12
12
  export { describeNotifier } from '#core/http-notifier.js';
13
- export { Scheduler, ScheduleError, FileSchedulerStore, getSchedulerStore, setSchedulerStore, createScheduledTask, listScheduledTasks, getScheduledTask, cancelScheduledTask, pauseScheduledTask, resumeScheduledTask, runScheduledTaskNow, deleteScheduledTask, nextScheduledWakeAt, importLegacyScheduledTasks, parseEvery, describeSchedule, type ScheduledTask, type ScheduledTaskRun, type ScheduledTaskStatus, type Schedule, type SchedulerStore, type FireTask, type CreateScheduledTaskInput } from '#core/scheduler.js';
13
+ export { Scheduler, ScheduleError, FileSchedulerStore, getSchedulerStore, setSchedulerStore, createScheduledTask, listScheduledTasks, getScheduledTask, cancelScheduledTask, pauseScheduledTask, resumeScheduledTask, runScheduledTaskNow, deleteScheduledTask, nextScheduledWakeAt, moveScheduledTasksToTimeZone, importLegacyScheduledTasks, parseEvery, describeSchedule, type ScheduledTask, type ScheduledTaskRun, type ScheduledTaskStatus, type Schedule, type SchedulerStore, type FireTask, type CreateScheduledTaskInput } from '#core/scheduler.js';
14
14
  export { NOTIFIER_SLACK_SIGNING_SECRET_VAR } from '#core/notifier-file.js';
15
15
  export { firstSighting, handleInboundMessage, handleMessagingAction, settleThreadChoice, isPlainApproval, type MessagingDeps, type MessagingDecision, type DecisionOutcome } from '#core/messaging/engine.js';
16
16
  export { formatArgs, formatChange, labelFor } from '#core/messaging/format-args.js';
@@ -88,10 +88,11 @@ export { installAbility, upgradeAbility, removeAbility, listInstalledAbilities,
88
88
  export type { InstalledAbilityRecord, AbilityEnvDecl, UpgradeFileResult } from './bin/ability-manager.js';
89
89
  export { searchPublicAbilities, fetchLatestAbilityVersion } from './web/abilities-admin.js';
90
90
  export type { PublicAbilitySearchResult } from './web/abilities-admin.js';
91
- export { describeModelProviders, describeGateways, setModelProviderKey, clearModelProviderKey, ModelKeyError } from './web/global-config.js';
91
+ export { describeModelProviders, describeGateways, setModelProviderKey, clearModelProviderKey, ModelKeyError, describeTimeZone, setWorkspaceTimeZone, TimeZoneError } from './web/global-config.js';
92
92
  export { loopengineVersion } from './core/version.js';
93
93
  export { describeUpgrade, runUpgrade, UpgradeError } from './web/upgrade-admin.js';
94
- export type { ModelProviderInfo, AgentModelUsage, ModelsView, GatewayProviderInfo, GatewaysView } from './web/global-config.js';
94
+ export type { ModelProviderInfo, AgentModelUsage, ModelsView, GatewayProviderInfo, GatewaysView, TimeZoneView } from './web/global-config.js';
95
+ export { workspaceTimeZone, serverTimeZone, isValidTimeZone, TIME_ZONE_ENV } from '#core/time-zone.js';
95
96
  export { createTrackedApprover, listApprovals, decideApproval, findApproval } from './web/web-approver.js';
96
97
  export { scaffoldAgent, scaffoldSubagent, AgentNameError, AgentExistsError, AgentNotFoundError, AgentModelError } from './bin/cli.js';
97
98
  export type { AgentTemplateOptions } from './bin/cli.js';
package/dist/index.js CHANGED
@@ -12,7 +12,7 @@ export { setUsageSink, recordUsage } from '#core/usage.js';
12
12
  export { readNotifierFile, writeNotifierFile, removeNotifierFile, validateNotifierFile, notifierConfigFromFile, NotifierFileError, NOTIFIER_SECRET_VARS } from '#core/notifier-file.js';
13
13
  export { listDeliveries, recordDelivery } from '#core/notifier-deliveries.js';
14
14
  export { describeNotifier } from '#core/http-notifier.js';
15
- export { Scheduler, ScheduleError, FileSchedulerStore, getSchedulerStore, setSchedulerStore, createScheduledTask, listScheduledTasks, getScheduledTask, cancelScheduledTask, pauseScheduledTask, resumeScheduledTask, runScheduledTaskNow, deleteScheduledTask, nextScheduledWakeAt, importLegacyScheduledTasks, parseEvery, describeSchedule } from '#core/scheduler.js';
15
+ export { Scheduler, ScheduleError, FileSchedulerStore, getSchedulerStore, setSchedulerStore, createScheduledTask, listScheduledTasks, getScheduledTask, cancelScheduledTask, pauseScheduledTask, resumeScheduledTask, runScheduledTaskNow, deleteScheduledTask, nextScheduledWakeAt, moveScheduledTasksToTimeZone, importLegacyScheduledTasks, parseEvery, describeSchedule } from '#core/scheduler.js';
16
16
  export { NOTIFIER_SLACK_SIGNING_SECRET_VAR } from '#core/notifier-file.js';
17
17
  export { firstSighting, handleInboundMessage, handleMessagingAction, settleThreadChoice, isPlainApproval } from '#core/messaging/engine.js';
18
18
  export { formatArgs, formatChange, labelFor } from '#core/messaging/format-args.js';
@@ -78,8 +78,9 @@ export { listDeclaredEnvVars, setEnvVar, unsetEnvVar, setAgentEnvVar, unsetAgent
78
78
  export { readActauthConfig, addActauthRule, updateActauthRule, removeActauthRule, setDefaultDecision, ActauthRuleExistsError, ActauthRuleNotFoundError, } from './web/actauth-admin.js';
79
79
  export { installAbility, upgradeAbility, removeAbility, listInstalledAbilities, backfillDependencies, AbilityManifestError, AbilityVersionError, AbilityCollisionError, AbilityAlreadyInstalledError, AbilityNotInstalledError, } from './bin/ability-manager.js';
80
80
  export { searchPublicAbilities, fetchLatestAbilityVersion } from './web/abilities-admin.js';
81
- export { describeModelProviders, describeGateways, setModelProviderKey, clearModelProviderKey, ModelKeyError } from './web/global-config.js';
81
+ export { describeModelProviders, describeGateways, setModelProviderKey, clearModelProviderKey, ModelKeyError, describeTimeZone, setWorkspaceTimeZone, TimeZoneError } from './web/global-config.js';
82
82
  export { loopengineVersion } from './core/version.js';
83
83
  export { describeUpgrade, runUpgrade, UpgradeError } from './web/upgrade-admin.js';
84
+ export { workspaceTimeZone, serverTimeZone, isValidTimeZone, TIME_ZONE_ENV } from '#core/time-zone.js';
84
85
  export { createTrackedApprover, listApprovals, decideApproval, findApproval } from './web/web-approver.js';
85
86
  export { scaffoldAgent, scaffoldSubagent, AgentNameError, AgentExistsError, AgentNotFoundError, AgentModelError } from './bin/cli.js';