loopengine 0.1.93 → 0.1.94

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';
@@ -1484,10 +1484,22 @@ ${consoleShellScript}
1484
1484
  scheduled: 'scheduled', running: 'running', awaiting_approval: 'waiting for approval', paused: 'paused', done: 'done', cancelled: 'cancelled'
1485
1485
  };
1486
1486
 
1487
+ // The workspace's time zone, from GET /agents/:name/schedules (an
1488
+ // older project adapter doesn't send it: then the browser's own).
1489
+ var schedulesTimeZone = null;
1490
+
1491
+ function fmtScheduleTime(iso) {
1492
+ var d = new Date(iso);
1493
+ if (schedulesTimeZone) {
1494
+ try { return d.toLocaleString(undefined, { timeZone: schedulesTimeZone, timeZoneName: 'short' }); } catch (e) {}
1495
+ }
1496
+ return d.toLocaleString();
1497
+ }
1498
+
1487
1499
  function scheduleText(s) {
1488
1500
  // A repeating task's anchor is its first run, so it says when the
1489
1501
  // later ones fall (e.g. every 1d from 9:00).
1490
- return s.run_at ? 'Once, ' + new Date(s.run_at).toLocaleString() : 'Every ' + escapeHtml(s.every) + (s.anchor ? '<div class="hint">from ' + escapeHtml(new Date(s.anchor).toLocaleString()) + '</div>' : '');
1502
+ return s.run_at ? 'Once, ' + escapeHtml(fmtScheduleTime(s.run_at)) : 'Every ' + escapeHtml(s.every) + (s.anchor ? '<div class="hint">from ' + escapeHtml(fmtScheduleTime(s.anchor)) + '</div>' : '');
1491
1503
  }
1492
1504
 
1493
1505
  function renderScheduleRow(t) {
@@ -1499,17 +1511,17 @@ ${consoleShellScript}
1499
1511
  if (t.status === 'cancelled' || t.status === 'done') actions.push(['delete', 'Delete']);
1500
1512
  var last = t.last_run_at
1501
1513
  ? '<span class="le-pill ' + (t.last_status === 'ok' ? 'good' : 'bad') + '">' + escapeHtml(t.last_status) + '</span> ' +
1502
- '<span class="hint">' + escapeHtml(new Date(t.last_run_at).toLocaleString()) + '</span>' +
1514
+ '<span class="hint">' + escapeHtml(fmtScheduleTime(t.last_run_at)) + '</span>' +
1503
1515
  '<div class="schedule-result">' + escapeHtml(t.last_status === 'ok' ? (t.last_result || '') : (t.last_error || '')) + '</div>'
1504
1516
  : '<span class="hint">not run yet</span>';
1505
1517
  var history = (t.history || []).slice().reverse().map(function (h) {
1506
- return '<li><span class="le-pill ' + (h.status === 'ok' ? 'good' : 'bad') + '">' + escapeHtml(h.status) + '</span> <span class="hint">' + escapeHtml(new Date(h.ran_at).toLocaleString()) + '</span>' +
1518
+ return '<li><span class="le-pill ' + (h.status === 'ok' ? 'good' : 'bad') + '">' + escapeHtml(h.status) + '</span> <span class="hint">' + escapeHtml(fmtScheduleTime(h.ran_at)) + '</span>' +
1507
1519
  '<div class="schedule-result">' + escapeHtml(h.status === 'ok' ? (h.result || '') : (h.error || '')) + '</div></li>';
1508
1520
  }).join('');
1509
1521
  return '<tr>' +
1510
1522
  '<td style="min-width:180px"><strong>' + escapeHtml(t.task_name || 'Untitled') + '</strong>' + (t.notify ? ' <span class="le-pill accent" title="Each result goes through this agent&#39;s notifier">notifies</span>' : '') +
1511
1523
  '<div class="schedule-result hint">' + escapeHtml(t.message) + '</div></td>' +
1512
- '<td>' + scheduleText(t.schedule) + (t.next_run_at ? '<div class="hint">next ' + escapeHtml(new Date(t.next_run_at).toLocaleString()) + '</div>' : '') + '</td>' +
1524
+ '<td>' + scheduleText(t.schedule) + (t.next_run_at ? '<div class="hint">next ' + escapeHtml(fmtScheduleTime(t.next_run_at)) + '</div>' : '') + '</td>' +
1513
1525
  '<td><span class="le-pill ' + (SCHEDULE_PILLS[t.status] || 'neutral') + '">' + escapeHtml(SCHEDULE_LABELS[t.status] || t.status) + '</span>' +
1514
1526
  (t.status === 'awaiting_approval' ? '<div class="hint"><a href="#" class="schedule-to-approvals">Decide in Messaging</a></div>' : '') + '</td>' +
1515
1527
  '<td style="min-width:200px">' + last + (history ? '<details><summary class="hint">History (' + (t.history || []).length + ')</summary><ul class="schedule-history">' + history + '</ul></details>' : '') + '</td>' +
@@ -1533,7 +1545,8 @@ ${consoleShellScript}
1533
1545
  '<input type="number" name="everyN" value="1" min="1" aria-label="How many">' +
1534
1546
  '<select name="everyUnit" aria-label="Unit"><option value="m">minutes</option><option value="h" selected>hours</option><option value="d">days</option><option value="w">weeks</option></select>' +
1535
1547
  '<label for="everyStart">starting</label><input type="datetime-local" name="everyStart" id="everyStart" aria-label="First run"></div>' +
1536
- '<p class="hint" style="margin:0 0 4px 22px">Optional. The first run, which sets the time of day (and the weekday, for weeks) every later run keeps, e.g. every 1 day starting tomorrow 09:00. Without it, the first run is one interval from now.</p>' +
1548
+ '<p class="hint" style="margin:0 0 4px 22px">Optional. The first run, which sets the time of day (and the weekday, for weeks) every later run keeps, e.g. every 1 day starting tomorrow 09:00. Without it, the first run is one interval from now.' +
1549
+ (schedulesTimeZone ? ' Times are in ' + escapeHtml(schedulesTimeZone.replace(/_/g, ' ')) + ' (<a href="/config?section=settings">change</a>).' : '') + '</p>' +
1537
1550
  '<div class="schedule-when"><input type="radio" name="when" value="once" id="whenOnce"><label for="whenOnce">Once at</label>' +
1538
1551
  '<input type="datetime-local" name="runAt" aria-label="Date and time"></div>' +
1539
1552
  '</div>' +
@@ -1544,6 +1557,13 @@ ${consoleShellScript}
1544
1557
  '</form></section>';
1545
1558
  }
1546
1559
 
1560
+ // A date field's value ("2026-11-02T09:00") goes as it is, for the
1561
+ // server to read in the workspace's time zone; with no workspace time
1562
+ // zone known, it's the browser's.
1563
+ function scheduleFormTime(value) {
1564
+ return schedulesTimeZone ? value : new Date(value).toISOString();
1565
+ }
1566
+
1547
1567
  function wireSchedulesHandlers(name) {
1548
1568
  var content = schedulesContentEl();
1549
1569
  if (!content) return;
@@ -1576,14 +1596,13 @@ ${consoleShellScript}
1576
1596
  var schedule;
1577
1597
  if (data.get('when') === 'once') {
1578
1598
  if (!data.get('runAt')) { result.textContent = 'Pick a date and time.'; return; }
1579
- schedule = { run_at: new Date(data.get('runAt')).toISOString() };
1599
+ schedule = { run_at: scheduleFormTime(data.get('runAt')) };
1580
1600
  } else {
1581
- // Weeks go to the scheduler as days; the start comes from the
1582
- // browser's own time zone.
1601
+ // Weeks go to the scheduler as days.
1583
1602
  var unit = data.get('everyUnit');
1584
1603
  var n = Number(data.get('everyN')) || 1;
1585
1604
  schedule = { every: unit === 'w' ? String(n * 7) + 'd' : String(n) + unit };
1586
- if (data.get('everyStart')) schedule.start_at = new Date(data.get('everyStart')).toISOString();
1605
+ if (data.get('everyStart')) schedule.start_at = scheduleFormTime(data.get('everyStart'));
1587
1606
  }
1588
1607
  result.textContent = 'Scheduling' + String.fromCharCode(8230);
1589
1608
  fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/schedules', {
@@ -1604,6 +1623,7 @@ ${consoleShellScript}
1604
1623
  if (currentName !== name) return;
1605
1624
  if (!r.ok) throw new Error(r.body.error || 'request failed');
1606
1625
  var tasks = r.body.tasks || [];
1626
+ schedulesTimeZone = r.body.time_zone || null;
1607
1627
  content.innerHTML = renderSchedulesHtml(tasks);
1608
1628
  wireSchedulesHandlers(name);
1609
1629
  leShell.setCount('schedules', tasks.filter(function (t) { return t.status !== 'cancelled' && t.status !== 'done'; }).length);
@@ -298,14 +298,78 @@ ${consoleShellScript}
298
298
  ['Environment name', '<code>' + escapeHtml(d.environment) + '</code> <span class="hint">(LOOPENGINE_ENV)</span>'],
299
299
  ['Public address', d.publicUrl ? '<code>' + escapeHtml(d.publicUrl) + '</code>' : '<span class="hint">Not set (LOOPENGINE_PUBLIC_URL); links in notifications use the address saved with each notifier</span>'],
300
300
  ['Scheduled tasks', d.scheduler === 'off' ? 'Off on this server (LOOPENGINE_SCHEDULER=off)' : 'Run by this server'],
301
+ ['Time zone', d.timeZone ? '<code>' + escapeHtml(d.timeZone) + '</code>' : '<span class="hint">The server' + String.fromCharCode(8217) + 's own. Update the template below to choose one here.</span>'],
301
302
  ['Theme', 'Follows your system'],
302
303
  ];
303
304
  return '<section><table><thead><tr><th>Setting</th><th>Value</th></tr></thead><tbody>' +
304
305
  rows.map(function (r) { return '<tr><td>' + r[0] + '</td><td>' + r[1] + '</td></tr>'; }).join('') +
305
306
  '</tbody></table></section>' +
307
+ (d.timeZone ? '<section><h3>Time zone</h3><div class="source" id="timeZoneBox"><p class="hint">Loading' + String.fromCharCode(8230) + '</p></div></section>' : '') +
306
308
  '<section><h3>Updates</h3><div class="source" id="upgradeBox"><p class="hint">Checking for a newer LoopEngine' + String.fromCharCode(8230) + '</p></div></section>';
307
309
  }
308
310
 
311
+ // ---- Time zone ----
312
+
313
+ function renderTimeZone(t) {
314
+ var box = document.getElementById('timeZoneBox');
315
+ if (!box) return;
316
+ var intro = '<p>Schedules and templates use this for times of day: &ldquo;daily at 09:00&rdquo; runs at 9:00 here, and daily and weekly schedules keep that time when the clocks change. ' +
317
+ (t.chosen ? '' : 'Not chosen yet, so it' + String.fromCharCode(8217) + 's the server' + String.fromCharCode(8217) + 's (<code>' + escapeHtml(t.server) + '</code>).') + '</p>';
318
+ if (!t.canSave) {
319
+ box.innerHTML = intro + '<p>In use: <code>' + escapeHtml(t.timeZone) + '</code></p><p class="hint">Set LOOPENGINE_ADMIN_AUTH to change it here, or put <code>LOOPENGINE_TIME_ZONE</code> in .env.</p>';
320
+ return;
321
+ }
322
+ var browserZone = '';
323
+ try { browserZone = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; } catch (e) {}
324
+ var options = t.zones.map(function (z) {
325
+ return '<option value="' + escapeHtml(z) + '"' + (z === t.timeZone ? ' selected' : '') + '>' + escapeHtml(z.replace(/_/g, ' ')) + '</option>';
326
+ }).join('');
327
+ box.innerHTML = intro +
328
+ '<form id="timeZoneForm" style="display:flex;flex-direction:column;gap:10px;max-width:480px">' +
329
+ '<label for="timeZoneSelect">Time zone</label>' +
330
+ '<select id="timeZoneSelect" name="timeZone">' + options + '</select>' +
331
+ (browserZone && browserZone !== t.timeZone && t.zones.indexOf(browserZone) >= 0
332
+ ? '<p class="hint" style="margin:0"><a href="#" id="timeZoneUseBrowser">Use this browser' + String.fromCharCode(8217) + 's, ' + escapeHtml(browserZone) + '</a></p>' : '') +
333
+ '<label style="display:flex;gap:8px;align-items:center"><input type="checkbox" name="moveSchedules" checked> Move existing daily and weekly schedules too, keeping their time of day</label>' +
334
+ '<div><button type="submit" class="primary">Save</button></div>' +
335
+ '<div id="timeZoneMsg" class="hint"></div>' +
336
+ '</form>';
337
+ var form = document.getElementById('timeZoneForm');
338
+ var useBrowser = document.getElementById('timeZoneUseBrowser');
339
+ if (useBrowser) useBrowser.addEventListener('click', function (ev) { ev.preventDefault(); form.timeZone.value = browserZone; });
340
+ form.addEventListener('submit', function (ev) {
341
+ ev.preventDefault();
342
+ var msg = document.getElementById('timeZoneMsg');
343
+ var btn = form.querySelector('button[type="submit"]');
344
+ btn.disabled = true;
345
+ msg.textContent = 'Saving' + String.fromCharCode(8230);
346
+ fetch('/config/time-zone', {
347
+ method: 'PUT',
348
+ headers: { 'content-type': 'application/json' },
349
+ body: JSON.stringify({ timeZone: form.timeZone.value, moveSchedules: form.moveSchedules.checked }),
350
+ })
351
+ .then(function (r) { return r.json().then(function (b) { return { ok: r.ok, body: b }; }); })
352
+ .then(function (r) {
353
+ btn.disabled = false;
354
+ if (!r.ok) { msg.innerHTML = '<span class="error">' + escapeHtml(r.body.error || 'Could not save') + '</span>'; return; }
355
+ msg.textContent = 'Saved: ' + r.body.timeZone.replace(/_/g, ' ') + '.' +
356
+ (r.body.moved ? ' Moved ' + r.body.moved + ' schedule' + (r.body.moved === 1 ? '' : 's') + '.' : '');
357
+ })
358
+ .catch(function (err) { btn.disabled = false; msg.innerHTML = '<span class="error">' + escapeHtml(err.message) + '</span>'; });
359
+ });
360
+ }
361
+
362
+ function loadTimeZone() {
363
+ if (!document.getElementById('timeZoneBox')) return;
364
+ fetch('/config/time-zone', { cache: 'no-store' })
365
+ .then(function (r) { return r.json(); })
366
+ .then(renderTimeZone)
367
+ .catch(function (err) {
368
+ var box = document.getElementById('timeZoneBox');
369
+ if (box) box.innerHTML = '<p class="error">Could not load the time zone: ' + escapeHtml(err.message) + '</p>';
370
+ });
371
+ }
372
+
309
373
  // ---- Upgrade ----
310
374
 
311
375
  var UPGRADE_NOTE = 'Updates this server' + String.fromCharCode(8217) + 's LoopEngine code and restarts it, which takes a minute or two. ' +
@@ -436,6 +500,7 @@ ${consoleShellScript}
436
500
  .then(function (d) {
437
501
  content.innerHTML = renderSettings(d);
438
502
  loadedSections.settings = true;
503
+ loadTimeZone();
439
504
  loadUpgrade();
440
505
  })
441
506
  .catch(function (err) {
@@ -69,3 +69,27 @@ export declare function modelKeyEnvVar(provider: string): string | undefined;
69
69
  */
70
70
  export declare function setModelProviderKey(provider: string, key: unknown): Promise<void>;
71
71
  export declare function clearModelProviderKey(provider: string): Promise<void>;
72
+ export declare class TimeZoneError extends Error {
73
+ }
74
+ export interface TimeZoneView {
75
+ /** The one in use. */
76
+ timeZone: string;
77
+ /** Whether it was chosen (LOOPENGINE_TIME_ZONE), not the server's. */
78
+ chosen: boolean;
79
+ server: string;
80
+ zones: string[];
81
+ }
82
+ export declare function describeTimeZone(): TimeZoneView;
83
+ /**
84
+ * The console's Settings → Time zone: saved like a model key (the
85
+ * project's .env by default) and applied to this process at once. New
86
+ * schedules and template start times use it from then on; with
87
+ * `moveSchedules`, existing daily and weekly schedules move to it too,
88
+ * keeping their time of day. An empty value goes back to the server's.
89
+ */
90
+ export declare function setWorkspaceTimeZone(value: unknown, options?: {
91
+ moveSchedules?: boolean;
92
+ }): Promise<{
93
+ timeZone: string;
94
+ moved: number;
95
+ }>;
@@ -1,4 +1,6 @@
1
1
  import { getComposioAuthStatus } from '../core/gateway-tools.js';
2
+ import { moveScheduledTasksToTimeZone } from '../core/scheduler.js';
3
+ import { TIME_ZONE_ENV, isValidTimeZone, listTimeZones, serverTimeZone, workspaceTimeZone } from '../core/time-zone.js';
2
4
  import { setEnvVar, unsetEnvVar } from './env-admin.js';
3
5
  export class ModelKeyError extends Error {
4
6
  }
@@ -102,3 +104,31 @@ export async function clearModelProviderKey(provider) {
102
104
  throw new ModelKeyError(`unknown provider '${provider}'`);
103
105
  await unsetEnvVar(envVar);
104
106
  }
107
+ export class TimeZoneError extends Error {
108
+ }
109
+ export function describeTimeZone() {
110
+ const timeZone = workspaceTimeZone();
111
+ const configured = process.env[TIME_ZONE_ENV]?.trim();
112
+ return { timeZone, chosen: Boolean(configured && isValidTimeZone(configured)), server: serverTimeZone(), zones: listTimeZones() };
113
+ }
114
+ /**
115
+ * The console's Settings → Time zone: saved like a model key (the
116
+ * project's .env by default) and applied to this process at once. New
117
+ * schedules and template start times use it from then on; with
118
+ * `moveSchedules`, existing daily and weekly schedules move to it too,
119
+ * keeping their time of day. An empty value goes back to the server's.
120
+ */
121
+ export async function setWorkspaceTimeZone(value, options = {}) {
122
+ const requested = typeof value === 'string' ? value.trim() : '';
123
+ if (requested && !isValidTimeZone(requested))
124
+ throw new TimeZoneError(`"${requested}" isn't a time zone this server knows, such as America/New_York or Europe/London`);
125
+ const before = workspaceTimeZone();
126
+ // Saved by its canonical name ("america/new_york" → "America/New_York").
127
+ if (requested)
128
+ await setEnvVar(TIME_ZONE_ENV, new Intl.DateTimeFormat('en-US', { timeZone: requested }).resolvedOptions().timeZone);
129
+ else
130
+ await unsetEnvVar(TIME_ZONE_ENV);
131
+ const timeZone = workspaceTimeZone();
132
+ const moved = options.moveSchedules && timeZone !== before ? await moveScheduledTasksToTimeZone(before, timeZone) : 0;
133
+ return { timeZone, moved };
134
+ }
@@ -70,6 +70,7 @@ export interface CreatedTemplateAgent {
70
70
  exampleMessage?: string;
71
71
  schedule?: {
72
72
  description: string;
73
+ timeZone?: string;
73
74
  next_run_at?: string;
74
75
  };
75
76
  /** Set when the schedule couldn't be created; the agent still was. */
@@ -9,6 +9,7 @@
9
9
  // rule. Its template's rules are written before its abilities' own, so
10
10
  // they take precedence where both name the same tool.
11
11
  import { createScheduledTask, pauseScheduledTask } from '../core/scheduler.js';
12
+ import { workspaceTimeZone } from '../core/time-zone.js';
12
13
  import { STARTER_TEMPLATES, starterTemplate, templateScheduleStart } from '../core/system-agents/templates.js';
13
14
  import { ActauthRuleExistsError, addActauthRule, setDefaultDecision } from './actauth-admin.js';
14
15
  import { setAgentEnvVar } from './env-admin.js';
@@ -168,7 +169,7 @@ export async function createFromTemplate(id, deps) {
168
169
  task_name: setup.schedule.name,
169
170
  });
170
171
  await pauseScheduledTask(task.task_id);
171
- schedule = { description: describeTemplateSchedule(setup.schedule), ...(startAt ? { next_run_at: startAt } : {}) };
172
+ schedule = { description: describeTemplateSchedule(setup.schedule), timeZone: workspaceTimeZone(), ...(startAt ? { next_run_at: startAt } : {}) };
172
173
  }
173
174
  catch (err) {
174
175
  scheduleError = err instanceof Error ? err.message : String(err);
@@ -210,7 +211,7 @@ async function createFromTemplateLegacy(id, deps) {
210
211
  const startAt = templateScheduleStart(setup.schedule);
211
212
  const task = await createScheduledTask({ agent: name, message: setup.schedule.message, schedule: { every: setup.schedule.every, ...(startAt ? { start_at: startAt } : {}) }, task_name: setup.schedule.name });
212
213
  await pauseScheduledTask(task.task_id);
213
- schedule = { description: describeTemplateSchedule(setup.schedule), ...(startAt ? { next_run_at: startAt } : {}) };
214
+ schedule = { description: describeTemplateSchedule(setup.schedule), timeZone: workspaceTimeZone(), ...(startAt ? { next_run_at: startAt } : {}) };
214
215
  }
215
216
  catch (err) {
216
217
  scheduleError = err instanceof Error ? err.message : String(err);
@@ -136,7 +136,7 @@ ${consoleShellScript}
136
136
  slot.innerHTML = '<div class="tp-preview">' +
137
137
  '<div><strong>Created <code>' + esc(c.name) + '</code>.</strong> Anything its permissions don' + String.fromCharCode(8217) + 't cover asks you first.</div>' +
138
138
  (installed ? '<div>' + CHECK + ' Installed ' + installed + '</div>' : '') +
139
- (c.schedule ? '<div>' + CHECK + ' Schedule created, paused: ' + esc(c.schedule.description) + (c.schedule.next_run_at ? ' (first run ' + esc(new Date(c.schedule.next_run_at).toLocaleString()) + ' once turned on)' : '') + '</div>' : '') +
139
+ (c.schedule ? '<div>' + CHECK + ' Schedule created, paused: ' + esc(c.schedule.description) + (c.schedule.next_run_at ? ' (first run ' + esc(new Date(c.schedule.next_run_at).toLocaleString(undefined, c.schedule.timeZone ? { timeZone: c.schedule.timeZone, timeZoneName: 'short' } : undefined)) + ' once turned on)' : '') + '</div>' : '') +
140
140
  (c.loadError ? '<div class="error">It was written but couldn' + String.fromCharCode(8217) + 't be loaded: ' + esc(c.loadError) + '</div>' : '') +
141
141
  (c.scheduleError ? '<div class="error">Its schedule wasn' + String.fromCharCode(8217) + 't created: ' + esc(c.scheduleError) + '. Add one in its Schedules tab.</div>' : '') +
142
142
  '<div><strong>Finish setup:</strong>' + checklistHtml(c.checklist) + '</div>' +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopengine",
3
- "version": "0.1.93",
3
+ "version": "0.1.94",
4
4
  "type": "module",
5
5
  "description": "Runtime for tool-using AI agents with a transparent ReAct loop.",
6
6
  "license": "MIT",
@@ -57,6 +57,8 @@
57
57
  "dist/core/messaging",
58
58
  "dist/core/scheduler.js",
59
59
  "dist/core/scheduler.d.ts",
60
+ "dist/core/time-zone.js",
61
+ "dist/core/time-zone.d.ts",
60
62
  "dist/core/session-store.js",
61
63
  "dist/core/session-store.d.ts",
62
64
  "dist/core/vector-index.js",