loopengine 0.1.92 → 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.
@@ -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';
@@ -74,8 +74,8 @@ export { interviewPageHtml } from './web/interview-page.js';
74
74
  export { InterviewError, answerInterview, createFromCandidate, createAgentFromCandidate, describeCandidate, deleteInterview, finishInterview, getInterview, interviewTools, listInterviews, pickAgentModel, pickInterviewModel, previewCandidate, startInterview, setInterviewProject, setupChecklist, type Interview, type InterviewAgentSource, type CandidatePreview, type CreatedAgent, type ChecklistItem, type CreateDeps, } from './web/interview-admin.js';
75
75
  export { runInterviewTurn, rankCandidates, validateCandidate, INTERVIEWER_PROMPT, type AgentCandidate, type InterviewStage } from './core/system-agents/interviewer.js';
76
76
  export { templatesPageHtml } from './web/templates-page.js';
77
- export { TemplateError, createFromTemplate, listTemplates, previewTemplate, type TemplateSummary } from './web/templates-admin.js';
78
- export { STARTER_TEMPLATES, starterTemplate, type StarterTemplate } from './core/system-agents/templates.js';
77
+ export { TemplateError, createFromTemplate, describeTemplateSchedule, listTemplates, previewTemplate, templateChecklist, type CreatedTemplateAgent, type LegacyTemplateCreateDeps, type TemplateChecklistItem, type TemplateCreateDeps, type TemplatePreview, type TemplateSummary, } from './web/templates-admin.js';
78
+ export { STARTER_TEMPLATES, starterTemplate, templateScheduleStart, type StarterTemplate, type TemplateRule, type TemplateSchedule, type TemplateSetup, type TemplateSkill, } from './core/system-agents/templates.js';
79
79
  export { globalConfigPageHtml } from './web/global-config-page.js';
80
80
  export { playgroundHtml } from './web/playground.js';
81
81
  export { readSkill, writeSkill, deleteSkill, SkillInvalidIdError, SkillNotFoundError } from './web/skills-admin.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';
@@ -69,8 +69,8 @@ export { interviewPageHtml } from './web/interview-page.js';
69
69
  export { InterviewError, answerInterview, createFromCandidate, createAgentFromCandidate, describeCandidate, deleteInterview, finishInterview, getInterview, interviewTools, listInterviews, pickAgentModel, pickInterviewModel, previewCandidate, startInterview, setInterviewProject, setupChecklist, } from './web/interview-admin.js';
70
70
  export { runInterviewTurn, rankCandidates, validateCandidate, INTERVIEWER_PROMPT } from './core/system-agents/interviewer.js';
71
71
  export { templatesPageHtml } from './web/templates-page.js';
72
- export { TemplateError, createFromTemplate, listTemplates, previewTemplate } from './web/templates-admin.js';
73
- export { STARTER_TEMPLATES, starterTemplate } from './core/system-agents/templates.js';
72
+ export { TemplateError, createFromTemplate, describeTemplateSchedule, listTemplates, previewTemplate, templateChecklist, } from './web/templates-admin.js';
73
+ export { STARTER_TEMPLATES, starterTemplate, templateScheduleStart, } from './core/system-agents/templates.js';
74
74
  export { globalConfigPageHtml } from './web/global-config-page.js';
75
75
  export { playgroundHtml } from './web/playground.js';
76
76
  export { readSkill, writeSkill, deleteSkill, SkillInvalidIdError, SkillNotFoundError } from './web/skills-admin.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';
@@ -641,7 +641,7 @@ ${consoleShellScript}
641
641
  var messagingDetail = [];
642
642
  if (pending.length) messagingDetail.push(pending.length + ' waiting for a decision');
643
643
  if (n && connected && n.channel === 'slack' && n.plan !== 'free' && (!n.file || n.file.conversations !== false)) messagingDetail.push('answers replies in threads');
644
- if (n && !connected) messagingDetail.push(n.missingSecret ? 'Saved, but its secret isn' + String.fromCharCode(8217) + 't set' : 'Send approvals and questions to Slack, Lark or a webhook');
644
+ if (n && !connected) messagingDetail.push(n.missingSecret ? 'Saved, but its secret isn' + String.fromCharCode(8217) + 't set' : 'Send approvals and questions to Slack, Lark, email or a webhook');
645
645
  if (!messagingDetail.length && n) messagingDetail.push('Sends ' + escapeHtml((n.events || []).join(', ')));
646
646
  set('ovMessagingDetail', messagingDetail.join(' \u00b7 '));
647
647
  showPendingCount(pending.length);
@@ -1076,7 +1076,7 @@ ${consoleShellScript}
1076
1076
  if (n.source === 'file') return 'Sending to <strong>' + escapeHtml(n.channel) + '</strong> for: ' + escapeHtml((n.events || []).join(', ')) + '.';
1077
1077
  if (n.missingSecret) return 'Saved, but not sending yet: set the secret below (<code>' + escapeHtml(n.missingSecret) + '</code>).';
1078
1078
  if (n.source === 'default') return 'Using the project-wide default webhook (<code>LOOPENGINE_DEFAULT_WEBHOOK_URL</code>, ' + escapeHtml(n.defaultWebhookUrl) + '). Set one below to use a different target for this agent.';
1079
- return '<strong>Nowhere yet</strong>, so nobody is notified. Choose a webhook, Slack channel or Lark chat below.';
1079
+ return '<strong>Nowhere yet</strong>, so nobody is notified. Choose a Slack channel, Lark chat, email address or webhook below.';
1080
1080
  }
1081
1081
 
1082
1082
  // Slack only: whether replies in a request's thread are answered. On
@@ -1114,7 +1114,7 @@ ${consoleShellScript}
1114
1114
  function secretPlaceholder(isSet, missing) {
1115
1115
  return isSet ? 'Saved ' + String.fromCharCode(8212) + ' type a new one only to replace it' : missing;
1116
1116
  }
1117
- var SECRET_HINTS = { slack: 'xoxb-' + String.fromCharCode(8230) + ' from OAuth & Permissions', webhook: 'A long random string, or Generate', lark: 'App Secret from the Lark developer console' };
1117
+ var SECRET_HINTS = { slack: 'xoxb-' + String.fromCharCode(8230) + ' from OAuth & Permissions', webhook: 'A long random string, or Generate', lark: 'App Secret from the Lark developer console', email: 'An API key from your email provider' };
1118
1118
  function secretField(channel, label) {
1119
1119
  var isSet = n.secretsSet && n.secretsSet[channel];
1120
1120
  return '<label>' + secretHead(label, isSet) + '<span class="hint">(stored in this agent&#39;s env as ' + escapeHtml(n.secretVars[channel]) + ')</span>' +
@@ -1123,7 +1123,7 @@ ${consoleShellScript}
1123
1123
  }
1124
1124
  return '<form class="add-source" id="notifierForm" style="max-width:560px">' +
1125
1125
  '<label>Send to<select name="channel" id="notifierChannel">' +
1126
- ['webhook', 'slack', 'lark'].map(function (c) { return '<option value="' + c + '"' + (f.channel === c ? ' selected' : '') + '>' + { webhook: 'Webhook (your app)', slack: 'Slack channel', lark: 'Lark / Feishu chat' }[c] + '</option>'; }).join('') +
1126
+ ['webhook', 'slack', 'lark', 'email'].map(function (c) { return '<option value="' + c + '"' + (f.channel === c ? ' selected' : '') + '>' + { webhook: 'Webhook (your app)', slack: 'Slack channel', lark: 'Lark / Feishu chat', email: 'Email' }[c] + '</option>'; }).join('') +
1127
1127
  '</select></label>' +
1128
1128
  field('webhook', '<label>Webhook URL<input type="url" name="url" placeholder="https://your-app.example.com/hooks/loopengine" value="' + escapeHtml(f.url || '') + '"></label>' + secretField('webhook', 'Signing secret') +
1129
1129
  '<p class="hint" style="margin:0">Your app checks the <code>X-Actauth-Signature</code> header (HMAC-SHA256 of the body with the signing secret). Each payload carries a <code>decideUrl</code> it can simply forward to a person, or it can decide itself by sending <code>{"decision":"approve"}</code> or <code>{"decision":"deny"}</code> to <code>resolveUrl</code>.</p>') +
@@ -1149,6 +1149,14 @@ ${consoleShellScript}
1149
1149
  renderConversationsField(n, f) +
1150
1150
  '<p class="hint" style="margin:0" id="slackEventsNote"></p>') +
1151
1151
  field('lark', '<label>App ID<input name="appId" value="' + escapeHtml(f.appId || '') + '"></label><label>Chat ID<input name="chatId" value="' + escapeHtml(f.chatId || '') + '"></label>' + secretField('lark', 'App secret')) +
1152
+ field('email',
1153
+ '<label>Email addresses <span class="hint">(who gets the requests; separate several with commas)</span><input name="emailTo" placeholder="ops@example.com, lead@example.com" value="' + escapeHtml(f.to || '') + '"></label>' +
1154
+ '<label>Sent by <span class="hint">(your email provider must allow sending from this address)</span><input name="emailFrom" placeholder="LoopEngine &lt;approvals@example.com&gt;" value="' + escapeHtml(f.from || '') + '"></label>' +
1155
+ '<label>Email provider<select name="emailProvider">' +
1156
+ [['resend', 'Resend'], ['sendgrid', 'SendGrid'], ['postmark', 'Postmark']].map(function (p) { return '<option value="' + p[0] + '"' + (f.provider === p[0] ? ' selected' : '') + '>' + p[1] + '</option>'; }).join('') +
1157
+ '</select></label>' +
1158
+ secretField('email', 'API key') +
1159
+ '<p class="hint" style="margin:0">Each email links to a page where the person approves, denies, edits or answers, with no login. The link address below must be one they can open.</p>') +
1152
1160
  '<label>Link address <span class="hint">(messages link here so people can approve without logging in; use an address they can reach)</span>' +
1153
1161
  '<input type="url" name="publicUrl" id="notifierPublicUrl" value="' + escapeHtml(n.publicUrl || f.publicUrl || location.origin) + '"' + (n.publicUrl ? ' disabled title="Set by LOOPENGINE_PUBLIC_URL"' : '') + '></label>' +
1154
1162
  '<div class="body-field">Send' +
@@ -1392,6 +1400,7 @@ ${consoleShellScript}
1392
1400
  var data = new FormData(form);
1393
1401
  var channel = data.get('channel');
1394
1402
  var body = { channel: channel, events: data.getAll('events'), url: data.get('url'), channelId: data.get('channelId'), appId: data.get('appId'), chatId: data.get('chatId'), publicUrl: data.get('publicUrl') };
1403
+ if (channel === 'email') { body.to = data.get('emailTo'); body.from = data.get('emailFrom'); body.provider = data.get('emailProvider'); }
1395
1404
  var secret = data.get('secret_' + channel);
1396
1405
  if (secret) body.secret = secret;
1397
1406
  if (channel === 'slack' && data.get('signingSecret')) body.signingSecret = data.get('signingSecret');
@@ -1475,8 +1484,22 @@ ${consoleShellScript}
1475
1484
  scheduled: 'scheduled', running: 'running', awaiting_approval: 'waiting for approval', paused: 'paused', done: 'done', cancelled: 'cancelled'
1476
1485
  };
1477
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
+
1478
1499
  function scheduleText(s) {
1479
- return s.run_at ? 'Once, ' + new Date(s.run_at).toLocaleString() : 'Every ' + escapeHtml(s.every);
1500
+ // A repeating task's anchor is its first run, so it says when the
1501
+ // later ones fall (e.g. every 1d from 9:00).
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>' : '');
1480
1503
  }
1481
1504
 
1482
1505
  function renderScheduleRow(t) {
@@ -1488,17 +1511,17 @@ ${consoleShellScript}
1488
1511
  if (t.status === 'cancelled' || t.status === 'done') actions.push(['delete', 'Delete']);
1489
1512
  var last = t.last_run_at
1490
1513
  ? '<span class="le-pill ' + (t.last_status === 'ok' ? 'good' : 'bad') + '">' + escapeHtml(t.last_status) + '</span> ' +
1491
- '<span class="hint">' + escapeHtml(new Date(t.last_run_at).toLocaleString()) + '</span>' +
1514
+ '<span class="hint">' + escapeHtml(fmtScheduleTime(t.last_run_at)) + '</span>' +
1492
1515
  '<div class="schedule-result">' + escapeHtml(t.last_status === 'ok' ? (t.last_result || '') : (t.last_error || '')) + '</div>'
1493
1516
  : '<span class="hint">not run yet</span>';
1494
1517
  var history = (t.history || []).slice().reverse().map(function (h) {
1495
- 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>' +
1496
1519
  '<div class="schedule-result">' + escapeHtml(h.status === 'ok' ? (h.result || '') : (h.error || '')) + '</div></li>';
1497
1520
  }).join('');
1498
1521
  return '<tr>' +
1499
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>' : '') +
1500
1523
  '<div class="schedule-result hint">' + escapeHtml(t.message) + '</div></td>' +
1501
- '<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>' +
1502
1525
  '<td><span class="le-pill ' + (SCHEDULE_PILLS[t.status] || 'neutral') + '">' + escapeHtml(SCHEDULE_LABELS[t.status] || t.status) + '</span>' +
1503
1526
  (t.status === 'awaiting_approval' ? '<div class="hint"><a href="#" class="schedule-to-approvals">Decide in Messaging</a></div>' : '') + '</td>' +
1504
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>' +
@@ -1520,7 +1543,10 @@ ${consoleShellScript}
1520
1543
  '<div class="body-field">When' +
1521
1544
  '<div class="schedule-when"><input type="radio" name="when" value="every" id="whenEvery" checked><label for="whenEvery">Every</label>' +
1522
1545
  '<input type="number" name="everyN" value="1" min="1" aria-label="How many">' +
1523
- '<select name="everyUnit" aria-label="Unit"><option value="m">minutes</option><option value="h" selected>hours</option><option value="d">days</option></select></div>' +
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>' +
1547
+ '<label for="everyStart">starting</label><input type="datetime-local" name="everyStart" id="everyStart" aria-label="First run"></div>' +
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>' +
1524
1550
  '<div class="schedule-when"><input type="radio" name="when" value="once" id="whenOnce"><label for="whenOnce">Once at</label>' +
1525
1551
  '<input type="datetime-local" name="runAt" aria-label="Date and time"></div>' +
1526
1552
  '</div>' +
@@ -1531,6 +1557,13 @@ ${consoleShellScript}
1531
1557
  '</form></section>';
1532
1558
  }
1533
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
+
1534
1567
  function wireSchedulesHandlers(name) {
1535
1568
  var content = schedulesContentEl();
1536
1569
  if (!content) return;
@@ -1563,9 +1596,13 @@ ${consoleShellScript}
1563
1596
  var schedule;
1564
1597
  if (data.get('when') === 'once') {
1565
1598
  if (!data.get('runAt')) { result.textContent = 'Pick a date and time.'; return; }
1566
- schedule = { run_at: new Date(data.get('runAt')).toISOString() };
1599
+ schedule = { run_at: scheduleFormTime(data.get('runAt')) };
1567
1600
  } else {
1568
- schedule = { every: String(data.get('everyN')) + data.get('everyUnit') };
1601
+ // Weeks go to the scheduler as days.
1602
+ var unit = data.get('everyUnit');
1603
+ var n = Number(data.get('everyN')) || 1;
1604
+ schedule = { every: unit === 'w' ? String(n * 7) + 'd' : String(n) + unit };
1605
+ if (data.get('everyStart')) schedule.start_at = scheduleFormTime(data.get('everyStart'));
1569
1606
  }
1570
1607
  result.textContent = 'Scheduling' + String.fromCharCode(8230);
1571
1608
  fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/schedules', {
@@ -1586,6 +1623,7 @@ ${consoleShellScript}
1586
1623
  if (currentName !== name) return;
1587
1624
  if (!r.ok) throw new Error(r.body.error || 'request failed');
1588
1625
  var tasks = r.body.tasks || [];
1626
+ schedulesTimeZone = r.body.time_zone || null;
1589
1627
  content.innerHTML = renderSchedulesHtml(tasks);
1590
1628
  wireSchedulesHandlers(name);
1591
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
+ }
@@ -143,7 +143,7 @@ function activeNotifierTarget(config, defaultWebhook) {
143
143
  const dir = agentDirFor(config);
144
144
  const file = readNotifierFile(dir);
145
145
  if (file) {
146
- const { config: fromFile } = notifierConfigFromFile(file, createAgentEnv(config.name, dir));
146
+ const { config: fromFile } = notifierConfigFromFile(file, createAgentEnv(config.name, dir), config.name);
147
147
  if (fromFile)
148
148
  return fromFile;
149
149
  }
@@ -185,7 +185,7 @@ export async function testAgentNotifier(config, defaultWebhook) {
185
185
  const dir = agentDirFor(config);
186
186
  const file = readNotifierFile(dir);
187
187
  if (file) {
188
- const { config: fromFile, missingSecret } = notifierConfigFromFile(file, createAgentEnv(config.name, dir));
188
+ const { config: fromFile, missingSecret } = notifierConfigFromFile(file, createAgentEnv(config.name, dir), config.name);
189
189
  if (!fromFile)
190
190
  return { ok: false, channel: file.channel, error: `${missingSecret} is not set for this agent` };
191
191
  target = fromFile;
@@ -202,6 +202,15 @@ export async function testAgentNotifier(config, defaultWebhook) {
202
202
  if (target.channel === 'slack') {
203
203
  return { channel: 'slack', ...(await sendTestSlackMessage(target.config.botToken, target.config.channelId, `Test from LoopEngine: approvals for ${config.name} will be posted here.`)) };
204
204
  }
205
+ if (target.channel === 'email') {
206
+ try {
207
+ await target.config.sendEmail(target.config.to, `Test from LoopEngine: ${config.name}`, `<p>This is a test. Approval requests and questions from <b>${config.name}</b> will be emailed here, each with a link to approve, deny or answer.</p>`, { kind: 'test' });
208
+ return { channel: 'email', ok: true };
209
+ }
210
+ catch (err) {
211
+ return { channel: 'email', ok: false, error: err instanceof Error ? err.message : String(err) };
212
+ }
213
+ }
205
214
  return { ok: false, channel: target.channel, error: `Send test isn't supported for ${target.channel} yet` };
206
215
  }
207
216
  /** Every decision still waiting for this agent, oldest first, each with