loopengine 0.1.69 → 0.1.70

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
@@ -121,6 +121,7 @@ code changes or redeploy needed for most of it:
121
121
  | **Tools** | Your hand-written tools, tools from integrations (e.g. [Composio](https://composio.dev)) and subagents, with whether each call runs on its own, asks first, or is denied. Build an HTTP tool or connect an integration without touching a file. |
122
122
  | **Skills** | Create, edit, and delete `SKILL.md` files for this agent directly in the browser — write the body, preview the rendered markdown, save. |
123
123
  | **Permissions** | Add, edit, and delete permission rules (actauth) — who, tool, decision — and choose what happens when no rule matches. |
124
+ | **Schedules** | Messages the agent receives on a timer — once at a set time, or every N minutes/hours/days. Run now, pause, resume, cancel, see each run's result, and optionally send results through the agent's notifier. |
124
125
  | **Environment** | Every setting an installed ability reads, with its project-wide value and this agent's own value side by side, and which one the agent actually uses. A secret is never shown again once set. |
125
126
 
126
127
  Models and Integrations in the sidebar show which model providers have a
@@ -211,6 +212,30 @@ click, a webhook HMAC signature, a magic-link token) ship under
211
212
  live and durable, worked examples, and how resumption actually works
212
213
  under the hood.
213
214
 
215
+ ## Scheduled tasks
216
+
217
+ The server runs one scheduler (`core/scheduler.ts`) that sends a message
218
+ to an agent later — once (`run_at`) or on an interval (`every: "1h"`) —
219
+ from the console's **Schedules** tab, the `GET/POST
220
+ /agents/<name>/schedules` API, or the agent itself: every agent has
221
+ built-in `system_schedule_task`, `system_list_scheduled_tasks`,
222
+ `system_check_scheduled_task` and `system_cancel_scheduled_task` tools,
223
+ so "remind me tomorrow at 9" just works. An
224
+ agent can only schedule, see or cancel its own tasks, up to 25 active at
225
+ a time (`LOOPENGINE_SCHEDULER_MAX_PER_AGENT`); `selfScheduling: false` in
226
+ its config removes the tools. Tasks left by the old `lp-task-scheduler`
227
+ ability are imported at start. Each run goes through the same
228
+ path as an HTTP call: an approval goes wherever the Permissions tab sends
229
+ it (and resumes the run once decided); with nowhere set, a call that
230
+ needs approval is denied. Intervals keep their original slot (an hourly
231
+ task created at 10:07 stays on :07); a run missed while the server was
232
+ down fires once when it's back. Tasks live in `.scheduler/` (or
233
+ `LOOPENGINE_SCHEDULER_DIR`); the tick is `LOOPENGINE_SCHEDULER_TICK_MS`
234
+ (10 s); `LOOPENGINE_SCHEDULER=off` turns the loop off for this process.
235
+ Behind a hosting platform, `GET /internal/activity` reports `nextWakeAt`
236
+ and `POST /internal/scheduler/tick` fires whatever is due, so a stopped
237
+ server can be woken in time.
238
+
214
239
  ## Ability system
215
240
 
216
241
  Giving an agent a new capability is usually three separate, hand-authored
@@ -399,6 +399,12 @@ export interface AgentConfig {
399
399
  * for `cli`/`http_stream`: both always get the library's own live
400
400
  * defaults, with nothing to configure. */
401
401
  httpNotifier?: HttpNotifierConfig;
402
+ /** Whether this agent gets the built-in scheduling tools
403
+ * (system_schedule_task and friends, core/system-tools/schedule.ts),
404
+ * which let it schedule messages to itself — reminders, recurring
405
+ * checks — and list or cancel its own scheduled tasks. Default true;
406
+ * false leaves them out of this agent's tools entirely. */
407
+ selfScheduling?: boolean;
402
408
  /** Fires once, fire-and-forget, at the start of every `runAgent()`/
403
409
  * `resumeAgent()` call on the `http` channel (or no channel at all —
404
410
  * see below) — the "someone triggered this agent" signal, for
@@ -19,7 +19,7 @@ import { recordUsage } from './usage.js';
19
19
  import { loadAgentModule } from './discover-agents.js';
20
20
  import { agentAsTool } from './agent-as-tool.js';
21
21
  import { loadGatewayToolsFromDir } from './gateway-tools.js';
22
- import { systemTools, createAskUserTool, CliQuestionHandler, isDurableQuestionHandler } from './system-tools/index.js';
22
+ import { systemTools, createAskUserTool, createScheduleTools, CliQuestionHandler, isDurableQuestionHandler } from './system-tools/index.js';
23
23
  import { resolveHttpNotifier } from './http-notifier.js';
24
24
  import { CRASH_RECOVERY_CONTINUATION } from './session-store.js';
25
25
  export { systemTools } from './system-tools/index.js';
@@ -296,9 +296,14 @@ async function buildTurnContext(config, modelCall, options) {
296
296
  // (options.onEvent's own fallback) — see RunAgentOptions.onQuestionPending's
297
297
  // own doc comment for why those two are deliberately not the same thing.
298
298
  const askUserTool = createAskUserTool({ agent: config.name, sessionId: options.sessionId }, options.onQuestionPending);
299
+ // Bound to this agent: it can schedule, list and cancel only its own
300
+ // tasks (see core/system-tools/schedule.ts). AgentConfig.selfScheduling
301
+ // false leaves them out.
302
+ const scheduleTools = config.selfScheduling === false ? [] : createScheduleTools(config.name);
299
303
  const tools = dedupeToolsByName([
300
304
  ...systemTools,
301
305
  askUserTool,
306
+ ...scheduleTools,
302
307
  ...(config.tools ?? (await loadDefaultTools(config))),
303
308
  ...(await loadSubagentAsTools(config)),
304
309
  ...(await loadGatewayToolsFromDir(join(process.cwd(), 'agents', config.name))),
@@ -390,7 +395,7 @@ async function buildTurnContext(config, modelCall, options) {
390
395
  // ('ask' or 'deny', see loadRules' own doc comment) would otherwise
391
396
  // catch it, since neither system tool ever appears in an agent's own
392
397
  // actauth.yml.
393
- const systemToolInstances = new Set([...systemTools, askUserTool]);
398
+ const systemToolInstances = new Set([...systemTools, askUserTool, ...scheduleTools]);
394
399
  const toolSchemas = tools.map(({ name, description, input_schema }) => ({
395
400
  name,
396
401
  description,
@@ -0,0 +1,143 @@
1
+ export type Schedule = {
2
+ run_at: string;
3
+ } | {
4
+ every: string;
5
+ anchor?: string;
6
+ };
7
+ export type ScheduledTaskStatus = 'scheduled' | 'running' | 'awaiting_approval' | 'paused' | 'done' | 'cancelled';
8
+ export interface ScheduledTaskRun {
9
+ ran_at: string;
10
+ status: 'ok' | 'error';
11
+ result?: string;
12
+ error?: string;
13
+ session_id?: string;
14
+ }
15
+ export interface ScheduledTask {
16
+ task_id: string;
17
+ task_name?: string;
18
+ agent: string;
19
+ message: string;
20
+ schedule: Schedule;
21
+ status: ScheduledTaskStatus;
22
+ created_at: string;
23
+ next_run_at?: string;
24
+ run_count: number;
25
+ last_run_at?: string;
26
+ last_status?: 'ok' | 'error';
27
+ last_result?: string;
28
+ last_error?: string;
29
+ /** Set while a run is paused on an approval or question. */
30
+ pending?: {
31
+ ran_at: string;
32
+ session_id: string;
33
+ pending_ids: string[];
34
+ };
35
+ /** Send each run's result through the agent's notifier. */
36
+ notify?: boolean;
37
+ history: ScheduledTaskRun[];
38
+ /** Set by "run now" on a paused or finished task, so it goes back to
39
+ * that status after the extra run. */
40
+ run_now_from?: 'paused' | 'done';
41
+ /** Set by cancelling a run that's waiting on an approval: cancelled
42
+ * once that run finishes. */
43
+ cancel_after_run?: boolean;
44
+ }
45
+ export declare class ScheduleError extends Error {
46
+ }
47
+ export declare function parseEvery(every: string): number;
48
+ /** Human-readable schedule, for lists and messages. */
49
+ export declare function describeSchedule(schedule: Schedule): string;
50
+ export interface SchedulerStore {
51
+ list(): Promise<ScheduledTask[]>;
52
+ get(taskId: string): Promise<ScheduledTask | undefined>;
53
+ put(task: ScheduledTask): Promise<void>;
54
+ remove(taskId: string): Promise<void>;
55
+ /** Runs `fn` with exclusive access to one task (read, change, write). */
56
+ update(taskId: string, fn: (task: ScheduledTask | undefined) => ScheduledTask | undefined): Promise<ScheduledTask | undefined>;
57
+ }
58
+ /** One JSON file per task under `<dir>/tasks/` — one server process. */
59
+ export declare class FileSchedulerStore implements SchedulerStore {
60
+ private readonly dir;
61
+ constructor(dir: string);
62
+ private file;
63
+ list(): Promise<ScheduledTask[]>;
64
+ get(taskId: string): Promise<ScheduledTask | undefined>;
65
+ put(task: ScheduledTask): Promise<void>;
66
+ remove(taskId: string): Promise<void>;
67
+ update(taskId: string, fn: (task: ScheduledTask | undefined) => ScheduledTask | undefined): Promise<ScheduledTask | undefined>;
68
+ }
69
+ /** The process-wide store: `.scheduler/` in the project, or
70
+ * LOOPENGINE_SCHEDULER_DIR. */
71
+ export declare function getSchedulerStore(): SchedulerStore;
72
+ export declare function setSchedulerStore(next: SchedulerStore): void;
73
+ export interface CreateScheduledTaskInput {
74
+ agent: string;
75
+ message: string;
76
+ schedule: {
77
+ run_at?: unknown;
78
+ every?: unknown;
79
+ };
80
+ task_name?: string;
81
+ notify?: boolean;
82
+ }
83
+ export declare function createScheduledTask(input: CreateScheduledTaskInput, nowMs?: number): Promise<ScheduledTask>;
84
+ export declare function listScheduledTasks(filter?: {
85
+ agent?: string;
86
+ status?: ScheduledTaskStatus;
87
+ }): Promise<ScheduledTask[]>;
88
+ export declare function getScheduledTask(taskId: string): Promise<ScheduledTask | undefined>;
89
+ /** Stops future runs; history stays. A run already waiting on an
90
+ * approval isn't retracted. */
91
+ export declare function cancelScheduledTask(taskId: string): Promise<ScheduledTask>;
92
+ export declare function pauseScheduledTask(taskId: string): Promise<ScheduledTask>;
93
+ export declare function resumeScheduledTask(taskId: string, nowMs?: number): Promise<ScheduledTask>;
94
+ /** Fires the task on the next tick, without changing its schedule. A
95
+ * finished one-off task runs once more. */
96
+ export declare function runScheduledTaskNow(taskId: string, nowMs?: number): Promise<ScheduledTask>;
97
+ export declare function deleteScheduledTask(taskId: string): Promise<void>;
98
+ /** When the next task is due — for a host that stops idle servers and
99
+ * needs to wake this one in time (GET /internal/activity). */
100
+ export declare function nextScheduledWakeAt(): Promise<string | undefined>;
101
+ /** Moves the old lp-task-scheduler's task files (`<dir>/.tasks/*.json`)
102
+ * into this store once, renaming each to `.json.migrated`. Returns how
103
+ * many it imported. */
104
+ export declare function importLegacyScheduledTasks(dir: string): Promise<number>;
105
+ /** How a task fires — supplied by the server. `done`: the agent replied;
106
+ * `pending`: the run is paused on approvals/questions (see settleFromResume). */
107
+ export type FireTask = (task: ScheduledTask, sessionId: string) => Promise<{
108
+ kind: 'done';
109
+ text: string;
110
+ } | {
111
+ kind: 'pending';
112
+ pendingIds: string[];
113
+ }>;
114
+ export declare class Scheduler {
115
+ private readonly fire;
116
+ private readonly tickMs;
117
+ private readonly onSettled?;
118
+ private readonly inFlight;
119
+ private timer?;
120
+ constructor(options: {
121
+ fire: FireTask;
122
+ tickMs?: number;
123
+ onSettled?: (task: ScheduledTask, run: ScheduledTaskRun) => void;
124
+ });
125
+ /** Imports tasks left by the old lp-task-scheduler ability, recovers
126
+ * runs a restart cut off, then ticks every `tickMs`. */
127
+ start(): Promise<void>;
128
+ stop(): void;
129
+ /** How many runs are executing right now (for idle-shutdown checks). */
130
+ get running(): number;
131
+ /** Fires every task that's due. Safe to call any time (a host's wake-up
132
+ * request calls it directly). */
133
+ tick(nowMs?: number): Promise<number>;
134
+ private run;
135
+ /** A paused run resumed after a decision (adapters/http.ts calls this
136
+ * from the resolve path): settle it, or record the next decision it's
137
+ * now waiting on. */
138
+ settleFromResume(sessionId: string, outcome: {
139
+ text: string;
140
+ pendingIds?: string[];
141
+ }): Promise<void>;
142
+ private settle;
143
+ }
@@ -0,0 +1,359 @@
1
+ // Scheduled tasks: "send this message to that agent later — once, or
2
+ // every N minutes". One scheduler per server, started at boot by
3
+ // adapters/http.ts, which also supplies how a task actually fires (the
4
+ // same turn path as POST /agents/:name/messages, so approvals, notifiers
5
+ // and decision links behave exactly as they do for any HTTP call).
6
+ //
7
+ // Everything a tool or the console needs — create, list, pause, cancel —
8
+ // works on the store directly (below), so it works whether or not this
9
+ // process is the one running the loop; the loop picks changes up on its
10
+ // next tick.
11
+ //
12
+ // `every` is anchored: an hourly task created at 10:07 keeps firing at
13
+ // :07, however long each run takes. A run missed while the server was
14
+ // down fires once when it's back, then the schedule moves to the next
15
+ // future slot (no burst of catch-up runs).
16
+ import { randomUUID } from 'node:crypto';
17
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
18
+ import path from 'node:path';
19
+ import { KeyedMutex } from './session-store.js';
20
+ const HISTORY_LIMIT = 20;
21
+ const EVERY_PATTERN = /^(\d+)\s*(s|m|h|d)$/;
22
+ const EVERY_UNIT_MS = { s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 };
23
+ /** Shortest allowed interval, so a typo can't hammer the model. */
24
+ const MIN_EVERY_MS = 30_000;
25
+ export class ScheduleError extends Error {
26
+ }
27
+ export function parseEvery(every) {
28
+ const match = EVERY_PATTERN.exec(every.trim());
29
+ if (!match)
30
+ throw new ScheduleError(`"${every}" is not a valid interval — use a number followed by s/m/h/d, e.g. "30m", "1h", "1d"`);
31
+ const ms = Number(match[1]) * EVERY_UNIT_MS[match[2]];
32
+ if (ms < MIN_EVERY_MS)
33
+ throw new ScheduleError(`"${every}" is too short — the shortest interval is 30s`);
34
+ return ms;
35
+ }
36
+ /** The first anchored slot strictly after `nowMs`. */
37
+ function nextAnchoredSlot(anchorMs, intervalMs, nowMs) {
38
+ if (nowMs < anchorMs)
39
+ return anchorMs;
40
+ return anchorMs + (Math.floor((nowMs - anchorMs) / intervalMs) + 1) * intervalMs;
41
+ }
42
+ /** Human-readable schedule, for lists and messages. */
43
+ export function describeSchedule(schedule) {
44
+ return 'run_at' in schedule ? `once at ${schedule.run_at}` : `every ${schedule.every}`;
45
+ }
46
+ const fileMutex = new KeyedMutex();
47
+ /** One JSON file per task under `<dir>/tasks/` — one server process. */
48
+ export class FileSchedulerStore {
49
+ dir;
50
+ constructor(dir) {
51
+ this.dir = path.join(dir, 'tasks');
52
+ }
53
+ file(taskId) {
54
+ if (!/^[A-Za-z0-9_-]+$/.test(taskId))
55
+ throw new ScheduleError(`invalid task id "${taskId}"`);
56
+ return path.join(this.dir, `${taskId}.json`);
57
+ }
58
+ async list() {
59
+ if (!existsSync(this.dir))
60
+ return [];
61
+ const tasks = [];
62
+ for (const name of readdirSync(this.dir)) {
63
+ if (!name.endsWith('.json'))
64
+ continue;
65
+ try {
66
+ tasks.push(JSON.parse(readFileSync(path.join(this.dir, name), 'utf8')));
67
+ }
68
+ catch {
69
+ // mid-write or removed between readdir and read
70
+ }
71
+ }
72
+ return tasks.sort((a, b) => a.created_at.localeCompare(b.created_at));
73
+ }
74
+ async get(taskId) {
75
+ const file = this.file(taskId);
76
+ return existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : undefined;
77
+ }
78
+ async put(task) {
79
+ mkdirSync(this.dir, { recursive: true });
80
+ const file = this.file(task.task_id);
81
+ writeFileSync(file + '.tmp', JSON.stringify(task, null, 2));
82
+ renameSync(file + '.tmp', file);
83
+ }
84
+ async remove(taskId) {
85
+ rmSync(this.file(taskId), { force: true });
86
+ }
87
+ async update(taskId, fn) {
88
+ return fileMutex.run(taskId, async () => {
89
+ const next = fn(await this.get(taskId));
90
+ if (next)
91
+ await this.put(next);
92
+ return next;
93
+ });
94
+ }
95
+ }
96
+ let store;
97
+ /** The process-wide store: `.scheduler/` in the project, or
98
+ * LOOPENGINE_SCHEDULER_DIR. */
99
+ export function getSchedulerStore() {
100
+ store ??= new FileSchedulerStore(process.env.LOOPENGINE_SCHEDULER_DIR || path.join(process.cwd(), '.scheduler'));
101
+ return store;
102
+ }
103
+ export function setSchedulerStore(next) {
104
+ store = next;
105
+ }
106
+ export async function createScheduledTask(input, nowMs = Date.now()) {
107
+ if (!input.agent)
108
+ throw new ScheduleError('agent is required');
109
+ if (!input.message?.trim())
110
+ throw new ScheduleError('message is required');
111
+ let schedule;
112
+ let nextRunMs;
113
+ if (typeof input.schedule?.run_at === 'string' && input.schedule.run_at) {
114
+ const runAtMs = Date.parse(input.schedule.run_at);
115
+ if (Number.isNaN(runAtMs))
116
+ throw new ScheduleError(`"${input.schedule.run_at}" is not a valid ISO 8601 timestamp`);
117
+ schedule = { run_at: new Date(runAtMs).toISOString() };
118
+ nextRunMs = runAtMs;
119
+ }
120
+ else if (typeof input.schedule?.every === 'string' && input.schedule.every) {
121
+ const intervalMs = parseEvery(input.schedule.every);
122
+ nextRunMs = nowMs + intervalMs;
123
+ schedule = { every: input.schedule.every.trim(), anchor: new Date(nextRunMs).toISOString() };
124
+ }
125
+ else {
126
+ throw new ScheduleError('schedule must be { "run_at": "<ISO timestamp>" } or { "every": "<interval, e.g. 1h>" }');
127
+ }
128
+ const task = {
129
+ task_id: randomUUID(),
130
+ task_name: input.task_name?.trim() || undefined,
131
+ agent: input.agent,
132
+ message: input.message,
133
+ schedule,
134
+ status: 'scheduled',
135
+ created_at: new Date(nowMs).toISOString(),
136
+ next_run_at: new Date(nextRunMs).toISOString(),
137
+ run_count: 0,
138
+ notify: input.notify || undefined,
139
+ history: [],
140
+ };
141
+ await getSchedulerStore().put(task);
142
+ return task;
143
+ }
144
+ export async function listScheduledTasks(filter = {}) {
145
+ return (await getSchedulerStore().list()).filter((t) => (!filter.agent || t.agent === filter.agent) && (!filter.status || t.status === filter.status));
146
+ }
147
+ export async function getScheduledTask(taskId) {
148
+ return getSchedulerStore().get(taskId);
149
+ }
150
+ function mustExist(task, taskId) {
151
+ if (!task)
152
+ throw new ScheduleError(`no scheduled task "${taskId}"`);
153
+ return task;
154
+ }
155
+ /** Stops future runs; history stays. A run already waiting on an
156
+ * approval isn't retracted. */
157
+ export async function cancelScheduledTask(taskId) {
158
+ return mustExist(await getSchedulerStore().update(taskId, (t) => !t ? t : t.status === 'awaiting_approval' ? { ...t, cancel_after_run: true } : { ...t, status: 'cancelled', next_run_at: undefined }), taskId);
159
+ }
160
+ export async function pauseScheduledTask(taskId) {
161
+ return mustExist(await getSchedulerStore().update(taskId, (t) => (t && t.status === 'scheduled' ? { ...t, status: 'paused', next_run_at: undefined } : t)), taskId);
162
+ }
163
+ export async function resumeScheduledTask(taskId, nowMs = Date.now()) {
164
+ return mustExist(await getSchedulerStore().update(taskId, (t) => {
165
+ if (!t || t.status !== 'paused')
166
+ return t;
167
+ return { ...t, status: 'scheduled', next_run_at: new Date(nextRunAfter(t, nowMs) ?? nowMs).toISOString() };
168
+ }), taskId);
169
+ }
170
+ /** Fires the task on the next tick, without changing its schedule. A
171
+ * finished one-off task runs once more. */
172
+ export async function runScheduledTaskNow(taskId, nowMs = Date.now()) {
173
+ return mustExist(await getSchedulerStore().update(taskId, (t) => {
174
+ if (!t || t.status === 'running' || t.status === 'awaiting_approval' || t.status === 'cancelled')
175
+ return t;
176
+ return { ...t, status: 'scheduled', next_run_at: new Date(nowMs).toISOString(), run_now_from: t.status === 'paused' || t.status === 'done' ? t.status : undefined };
177
+ }), taskId);
178
+ }
179
+ export async function deleteScheduledTask(taskId) {
180
+ await getSchedulerStore().remove(taskId);
181
+ }
182
+ /** When the next task is due — for a host that stops idle servers and
183
+ * needs to wake this one in time (GET /internal/activity). */
184
+ export async function nextScheduledWakeAt() {
185
+ const due = (await getSchedulerStore().list()).filter((t) => t.status === 'scheduled' && t.next_run_at).map((t) => t.next_run_at);
186
+ return due.sort()[0];
187
+ }
188
+ function nextRunAfter(task, nowMs) {
189
+ if ('run_at' in task.schedule)
190
+ return undefined;
191
+ const intervalMs = parseEvery(task.schedule.every);
192
+ const anchorMs = task.schedule.anchor ? Date.parse(task.schedule.anchor) : Date.parse(task.created_at) + intervalMs;
193
+ return nextAnchoredSlot(anchorMs, intervalMs, nowMs);
194
+ }
195
+ /** Moves the old lp-task-scheduler's task files (`<dir>/.tasks/*.json`)
196
+ * into this store once, renaming each to `.json.migrated`. Returns how
197
+ * many it imported. */
198
+ export async function importLegacyScheduledTasks(dir) {
199
+ const legacyDir = path.join(dir, '.tasks');
200
+ if (!existsSync(legacyDir))
201
+ return 0;
202
+ let imported = 0;
203
+ for (const name of readdirSync(legacyDir)) {
204
+ if (!name.endsWith('.json'))
205
+ continue;
206
+ const file = path.join(legacyDir, name);
207
+ try {
208
+ const old = JSON.parse(readFileSync(file, 'utf8'));
209
+ if (!(await getSchedulerStore().get(old.task_id))) {
210
+ const task = { ...old, history: old.history ?? [], run_count: old.run_count ?? 0 };
211
+ if ('every' in task.schedule && !task.schedule.anchor && task.next_run_at)
212
+ task.schedule = { ...task.schedule, anchor: task.next_run_at };
213
+ await getSchedulerStore().put(task);
214
+ imported++;
215
+ }
216
+ renameSync(file, file + '.migrated');
217
+ }
218
+ catch (err) {
219
+ console.error(`[loopengine] could not import legacy scheduled task ${file}:`, err);
220
+ }
221
+ }
222
+ return imported;
223
+ }
224
+ /** lp-task-scheduler 1.x kept tasks under
225
+ * generated/scheduled-tasks/<agent>/.tasks/ (or SCHEDULER_STORE_DIR) and
226
+ * ran its own timer; scheduling is built in now, so the server imports
227
+ * those once at start. */
228
+ async function importLegacyAbilityTasks() {
229
+ const dirs = new Set();
230
+ const root = path.join(process.cwd(), 'generated', 'scheduled-tasks');
231
+ if (existsSync(root)) {
232
+ dirs.add(root);
233
+ for (const name of readdirSync(root))
234
+ if (name !== '.tasks')
235
+ dirs.add(path.join(root, name));
236
+ }
237
+ if (process.env.SCHEDULER_STORE_DIR)
238
+ dirs.add(path.resolve(process.env.SCHEDULER_STORE_DIR));
239
+ for (const dir of dirs) {
240
+ const n = await importLegacyScheduledTasks(dir);
241
+ if (n)
242
+ console.log(`[loopengine] imported ${n} scheduled task(s) from ${dir}`);
243
+ }
244
+ }
245
+ export class Scheduler {
246
+ fire;
247
+ tickMs;
248
+ onSettled;
249
+ inFlight = new Set();
250
+ timer;
251
+ constructor(options) {
252
+ this.fire = options.fire;
253
+ this.tickMs = options.tickMs ?? (Number(process.env.LOOPENGINE_SCHEDULER_TICK_MS) || 10_000);
254
+ this.onSettled = options.onSettled;
255
+ }
256
+ /** Imports tasks left by the old lp-task-scheduler ability, recovers
257
+ * runs a restart cut off, then ticks every `tickMs`. */
258
+ async start() {
259
+ await importLegacyAbilityTasks();
260
+ for (const task of await getSchedulerStore().list()) {
261
+ if (task.status === 'running')
262
+ await this.settle(task.task_id, new Date().toISOString(), { status: 'error', error: 'The server restarted while this run was in progress.' });
263
+ }
264
+ await this.tick();
265
+ this.timer = setInterval(() => void this.tick().catch((err) => console.error('[loopengine] scheduler tick failed:', err)), this.tickMs);
266
+ this.timer.unref?.();
267
+ }
268
+ stop() {
269
+ if (this.timer)
270
+ clearInterval(this.timer);
271
+ }
272
+ /** How many runs are executing right now (for idle-shutdown checks). */
273
+ get running() {
274
+ return this.inFlight.size;
275
+ }
276
+ /** Fires every task that's due. Safe to call any time (a host's wake-up
277
+ * request calls it directly). */
278
+ async tick(nowMs = Date.now()) {
279
+ let fired = 0;
280
+ for (const task of await getSchedulerStore().list()) {
281
+ if (task.status !== 'scheduled' || !task.next_run_at || Date.parse(task.next_run_at) > nowMs || this.inFlight.has(task.task_id))
282
+ continue;
283
+ fired++;
284
+ this.inFlight.add(task.task_id);
285
+ void this.run(task.task_id).finally(() => this.inFlight.delete(task.task_id));
286
+ }
287
+ return fired;
288
+ }
289
+ async run(taskId) {
290
+ const ranAt = new Date().toISOString();
291
+ const claimed = await getSchedulerStore().update(taskId, (t) => (t && t.status === 'scheduled' ? { ...t, status: 'running' } : t));
292
+ if (!claimed || claimed.status !== 'running')
293
+ return;
294
+ const sessionId = `sched-${taskId.slice(0, 8)}-${claimed.run_count + 1}-${Date.now().toString(36)}`;
295
+ try {
296
+ const outcome = await this.fire(claimed, sessionId);
297
+ if (outcome.kind === 'pending') {
298
+ await getSchedulerStore().update(taskId, (t) => t && {
299
+ ...t,
300
+ status: 'awaiting_approval',
301
+ next_run_at: undefined,
302
+ pending: { ran_at: ranAt, session_id: sessionId, pending_ids: outcome.pendingIds },
303
+ // Cancelled while this run was starting: finish this run, then stop.
304
+ cancel_after_run: t.cancel_after_run || t.status === 'cancelled' || undefined,
305
+ });
306
+ return;
307
+ }
308
+ await this.settle(taskId, ranAt, { status: 'ok', result: outcome.text, session_id: sessionId });
309
+ }
310
+ catch (err) {
311
+ await this.settle(taskId, ranAt, { status: 'error', error: err instanceof Error ? err.message : String(err), session_id: sessionId });
312
+ }
313
+ }
314
+ /** A paused run resumed after a decision (adapters/http.ts calls this
315
+ * from the resolve path): settle it, or record the next decision it's
316
+ * now waiting on. */
317
+ async settleFromResume(sessionId, outcome) {
318
+ if (!sessionId.startsWith('sched-'))
319
+ return;
320
+ const task = (await getSchedulerStore().list()).find((t) => t.pending?.session_id === sessionId);
321
+ if (!task)
322
+ return;
323
+ if (outcome.pendingIds?.length) {
324
+ await getSchedulerStore().update(task.task_id, (t) => t && t.pending && { ...t, pending: { ...t.pending, pending_ids: outcome.pendingIds } });
325
+ return;
326
+ }
327
+ await this.settle(task.task_id, task.pending.ran_at, { status: 'ok', result: outcome.text, session_id: sessionId });
328
+ }
329
+ async settle(taskId, ranAt, run) {
330
+ const nowMs = Date.now();
331
+ const entry = { ran_at: ranAt, ...run };
332
+ const settled = await getSchedulerStore().update(taskId, (t) => {
333
+ if (!t)
334
+ return t;
335
+ const history = [...t.history, entry].slice(-HISTORY_LIMIT);
336
+ const base = {
337
+ ...t,
338
+ run_count: t.run_count + 1,
339
+ last_run_at: ranAt,
340
+ last_status: run.status,
341
+ last_result: run.result,
342
+ last_error: run.error,
343
+ pending: undefined,
344
+ history,
345
+ run_now_from: undefined,
346
+ cancel_after_run: undefined,
347
+ };
348
+ if (t.cancel_after_run || t.status === 'cancelled')
349
+ return { ...base, status: 'cancelled', next_run_at: undefined };
350
+ // A "run now" of a paused or finished task goes back to how it was.
351
+ if (t.run_now_from)
352
+ return { ...base, status: t.run_now_from, next_run_at: undefined };
353
+ const next = nextRunAfter(t, nowMs);
354
+ return next === undefined ? { ...base, status: 'done', next_run_at: undefined } : { ...base, status: 'scheduled', next_run_at: new Date(next).toISOString() };
355
+ });
356
+ if (settled)
357
+ this.onSettled?.(settled, entry);
358
+ }
359
+ }
@@ -1,3 +1,4 @@
1
1
  import type { ToolDefinition } from '../agent-config.js';
2
2
  export declare const systemTools: ToolDefinition[];
3
+ export { createScheduleTools } from './schedule.js';
3
4
  export { createAskUserTool, listQuestions, answerQuestion, findQuestion, CliQuestionHandler, WebchatQuestionHandler, isDurableQuestionHandler, type PendingQuestion, } from './ask_user.js';
@@ -9,4 +9,5 @@
9
9
  // re-exported here instead of an instance.
10
10
  import { readFile } from './read_file.js';
11
11
  export const systemTools = [readFile];
12
+ export { createScheduleTools } from './schedule.js';
12
13
  export { createAskUserTool, listQuestions, answerQuestion, findQuestion, CliQuestionHandler, WebchatQuestionHandler, isDurableQuestionHandler, } from './ask_user.js';
@@ -0,0 +1,2 @@
1
+ import type { ToolDefinition } from '../agent-config.js';
2
+ export declare function createScheduleTools(agentName: string): ToolDefinition[];
@@ -0,0 +1,111 @@
1
+ import { cancelScheduledTask, createScheduledTask, getScheduledTask, listScheduledTasks, } from '../scheduler.js';
2
+ /** How many active (not done or cancelled) tasks one agent may create
3
+ * through these tools — each run is a model call, so a runaway loop of
4
+ * "schedule another one" stops here. The console and API aren't capped. */
5
+ function maxActivePerAgent() {
6
+ const n = Number(process.env.LOOPENGINE_SCHEDULER_MAX_PER_AGENT);
7
+ return Number.isInteger(n) && n > 0 ? n : 25;
8
+ }
9
+ function summary(t) {
10
+ return {
11
+ task_id: t.task_id,
12
+ task_name: t.task_name,
13
+ message: t.message,
14
+ schedule: t.schedule,
15
+ status: t.status,
16
+ next_run_at: t.next_run_at,
17
+ run_count: t.run_count,
18
+ last_status: t.last_status,
19
+ };
20
+ }
21
+ export function createScheduleTools(agentName) {
22
+ async function ownTask(taskId, tool) {
23
+ const task = await getScheduledTask(taskId);
24
+ // Another agent's task reads exactly like a missing one.
25
+ if (!task || task.agent !== agentName)
26
+ throw new Error(`${tool}: no scheduled task "${taskId}" for this agent`);
27
+ return task;
28
+ }
29
+ const scheduleTask = {
30
+ 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.',
32
+ input_schema: {
33
+ type: 'object',
34
+ properties: {
35
+ message: {
36
+ type: 'string',
37
+ description: 'What you will receive when the task fires, written as an instruction to your future self with every detail it needs, e.g. "Check whether order 4417 has shipped and email the customer at a@example.com if it has."',
38
+ },
39
+ schedule: {
40
+ type: 'object',
41
+ description: 'Either { "run_at": "<ISO 8601 timestamp with time zone>" } 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 repeating task keeps its first slot: started at 10:07 with "1h", it fires at 11:07, 12:07, ...',
42
+ properties: { run_at: { type: 'string' }, every: { type: 'string' } },
43
+ },
44
+ task_name: { type: 'string', description: 'Optional short label, shown when listing tasks and in the console.' },
45
+ notify: { type: 'boolean', description: 'Optional — also send each run\'s result through your notifier (where your approval requests go), so a person sees it.' },
46
+ },
47
+ required: ['message', 'schedule'],
48
+ },
49
+ execute: async (input) => {
50
+ const active = (await listScheduledTasks({ agent: agentName })).filter((t) => t.status !== 'done' && t.status !== 'cancelled');
51
+ if (active.length >= maxActivePerAgent()) {
52
+ throw new Error(`system_schedule_task: you already have ${active.length} active scheduled tasks (the limit is ${maxActivePerAgent()}); cancel one you no longer need first`);
53
+ }
54
+ const task = await createScheduledTask({
55
+ agent: agentName,
56
+ message: String(input.message ?? ''),
57
+ schedule: (input.schedule ?? {}),
58
+ task_name: typeof input.task_name === 'string' ? input.task_name : undefined,
59
+ notify: input.notify === true,
60
+ });
61
+ return JSON.stringify({ task_id: task.task_id, next_run_at: task.next_run_at, schedule: task.schedule });
62
+ },
63
+ };
64
+ const listTasks = {
65
+ name: 'system_list_scheduled_tasks',
66
+ description: 'List your own scheduled tasks (task_id, name, message, schedule, status, next_run_at, run_count, last_status). Check this before scheduling something recurring, so you don\'t create a duplicate. status "awaiting_approval" means a run is waiting on someone to approve or answer.',
67
+ input_schema: {
68
+ type: 'object',
69
+ properties: {
70
+ status: {
71
+ type: 'string',
72
+ enum: ['scheduled', 'running', 'awaiting_approval', 'paused', 'done', 'cancelled'],
73
+ description: 'Optional — only tasks with this status.',
74
+ },
75
+ },
76
+ },
77
+ execute: async (input) => {
78
+ const status = typeof input.status === 'string' ? input.status : undefined;
79
+ return JSON.stringify((await listScheduledTasks({ agent: agentName, status })).map(summary));
80
+ },
81
+ safe: true,
82
+ };
83
+ const checkTask = {
84
+ name: 'system_check_scheduled_task',
85
+ description: 'Get one of your scheduled tasks in full — schedule, status, next_run_at, and recent runs, each "ok" with your reply from that run or "error" with what went wrong (for example, a call that needed approval was denied because nowhere is set to send approval requests).',
86
+ input_schema: {
87
+ type: 'object',
88
+ properties: { task_id: { type: 'string', description: 'The task_id from system_schedule_task or system_list_scheduled_tasks.' } },
89
+ required: ['task_id'],
90
+ },
91
+ execute: async (input) => JSON.stringify(await ownTask(String(input.task_id ?? ''), 'system_check_scheduled_task')),
92
+ safe: true,
93
+ };
94
+ const cancelTask = {
95
+ name: 'system_cancel_scheduled_task',
96
+ description: 'Cancel one of your scheduled tasks so it doesn\'t fire again. A run already waiting on an approval finishes first. Its history stays (status "cancelled").',
97
+ input_schema: {
98
+ type: 'object',
99
+ properties: { task_id: { type: 'string', description: 'The task_id from system_schedule_task or system_list_scheduled_tasks.' } },
100
+ required: ['task_id'],
101
+ },
102
+ execute: async (input) => {
103
+ const task = await ownTask(String(input.task_id ?? ''), 'system_cancel_scheduled_task');
104
+ if (task.status === 'cancelled')
105
+ return JSON.stringify({ task_id: task.task_id, status: 'cancelled', already: true });
106
+ const after = await cancelScheduledTask(task.task_id);
107
+ return JSON.stringify({ task_id: task.task_id, status: after.status, ...(after.cancel_after_run ? { note: 'cancels once the run waiting on an approval finishes' } : {}) });
108
+ },
109
+ };
110
+ return [scheduleTask, listTasks, checkTask, cancelTask];
111
+ }
package/dist/index.d.ts CHANGED
@@ -10,12 +10,13 @@ export { setUsageSink, recordUsage, type UsageEvent, type UsageSink } from '#cor
10
10
  export { readNotifierFile, writeNotifierFile, removeNotifierFile, validateNotifierFile, notifierConfigFromFile, NotifierFileError, NOTIFIER_SECRET_VARS, type NotifierFile } 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
14
  export { verifySlackRequest, parseSlackInteraction, openSlackAnswerModal, replyInSlack, slackOutcomeText, listSlackChannels, slackAppManifest, type SlackInteraction, type SlackChannel } from '#core/slack-interactions.js';
14
15
  export { NOTIFIER_SLACK_SIGNING_SECRET_VAR } from '#core/notifier-file.js';
15
16
  export { createDecisionToken, verifyDecisionToken, decidePath, decideLinkMaker, decisionLinkTtlMs, type DecisionKind } from '#core/approval-links.js';
16
17
  export { renderDecidePage, renderDecideGonePage, renderDecideInvalidPage, type DecidePageItem } from './web/decide-page.js';
17
18
  export { sendTestWebhook } from '#core/http-notify-triggers/webhook.js';
18
- export { describeAgentNotifier, saveAgentNotifier, removeAgentNotifier, testAgentNotifier, listAgentPending, slackCredentialsFor, listAgentSlackChannels, type DefaultWebhook } from './web/notifier-admin.js';
19
+ export { describeAgentNotifier, saveAgentNotifier, removeAgentNotifier, testAgentNotifier, listAgentPending, slackCredentialsFor, listAgentSlackChannels, announceToNotifier, type DefaultWebhook } from './web/notifier-admin.js';
19
20
  export type * from './core/loop-events.js';
20
21
  export { streamMessage, streamMessageWithCallbacks, sendMessage, approveCall, denyCall, answerQuestion, getSessionHistory, } from './core/client.js';
21
22
  export type { RequestOptions, SendMessageResult, PendingApprovalResult, PendingQuestionResult, PendingResult, MessageResult, StreamMessageCallbacks, } from './core/client.js';
@@ -56,7 +57,7 @@ export type { ToolSource, ComposioSourceOptions } from './core/mcpplug.js';
56
57
  export { ToolLane, buildLanes, runLanes } from './core/toollane.js';
57
58
  export type { ToolLaneOptions, SafetyClassifier, ToolCall, Lane, LaneResult } from './core/toollane.js';
58
59
  export { WebhookNotifier } from './core/http-notify-triggers/webhook.js';
59
- export { listQuestions, answerQuestion as resolvePendingQuestion, findQuestion, createAskUserTool } from './core/system-tools/index.js';
60
+ export { listQuestions, answerQuestion as resolvePendingQuestion, findQuestion, createAskUserTool, createScheduleTools } from './core/system-tools/index.js';
60
61
  export type { PendingQuestion } from './core/system-tools/index.js';
61
62
  export { editAgentFile, AgentEditNotSupportedError, AgentFileNotFoundError } from './web/agent-file-admin.js';
62
63
  export type { AgentEditableFields, AgentEditResult } from './web/agent-file-admin.js';
package/dist/index.js CHANGED
@@ -12,12 +12,13 @@ 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
16
  export { verifySlackRequest, parseSlackInteraction, openSlackAnswerModal, replyInSlack, slackOutcomeText, listSlackChannels, slackAppManifest } from '#core/slack-interactions.js';
16
17
  export { NOTIFIER_SLACK_SIGNING_SECRET_VAR } from '#core/notifier-file.js';
17
18
  export { createDecisionToken, verifyDecisionToken, decidePath, decideLinkMaker, decisionLinkTtlMs } from '#core/approval-links.js';
18
19
  export { renderDecidePage, renderDecideGonePage, renderDecideInvalidPage } from './web/decide-page.js';
19
20
  export { sendTestWebhook } from '#core/http-notify-triggers/webhook.js';
20
- export { describeAgentNotifier, saveAgentNotifier, removeAgentNotifier, testAgentNotifier, listAgentPending, slackCredentialsFor, listAgentSlackChannels } from './web/notifier-admin.js';
21
+ export { describeAgentNotifier, saveAgentNotifier, removeAgentNotifier, testAgentNotifier, listAgentPending, slackCredentialsFor, listAgentSlackChannels, announceToNotifier } from './web/notifier-admin.js';
21
22
  // The framework-agnostic browser client for adapters/http.ts's own wire
22
23
  // protocol (see client.ts's own header comment) — this, not a deep
23
24
  // subpath import, is how a consumer's React/Vue/etc. chat UI is meant to
@@ -55,7 +56,7 @@ export { WebhookNotifier } from './core/http-notify-triggers/webhook.js';
55
56
  // answerQuestion above (core/client.ts) is the HTTP client-side helper —
56
57
  // this is the server-side function that actually resolves a pending
57
58
  // question in-process, aliased to avoid colliding with that name.
58
- export { listQuestions, answerQuestion as resolvePendingQuestion, findQuestion, createAskUserTool } from './core/system-tools/index.js';
59
+ export { listQuestions, answerQuestion as resolvePendingQuestion, findQuestion, createAskUserTool, createScheduleTools } from './core/system-tools/index.js';
59
60
  export { editAgentFile, AgentEditNotSupportedError, AgentFileNotFoundError } from './web/agent-file-admin.js';
60
61
  export { createHttpTool, updateHttpTool, readHttpToolSpec, listEditableHttpToolNames, HttpToolNameError, HttpToolExistsError, HttpToolIndexShapeError, HttpToolNotFoundError, HttpToolNotEditableError, } from './web/http-tool-admin.js';
61
62
  export { agentsConfigPageHtml } from './web/agents-config-page.js';
@@ -39,6 +39,13 @@ export const agentsConfigPageHtml = `<!doctype html>
39
39
  panels' own content. */
40
40
  #empty { color: var(--ink-muted); font-size: 13px; padding: 24px; }
41
41
  .env-cell { display: grid; gap: 6px; }
42
+ .schedule-result { max-width: 420px; white-space: pre-wrap; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; font-size: 12.5px; }
43
+ .schedule-history { list-style: none; margin: 6px 0 0; padding: 0; display: grid; gap: 8px; }
44
+ form.add-source .schedule-when { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 13px; }
45
+ form.add-source .schedule-when label { display: inline; font-size: 13px; }
46
+ form.add-source .schedule-when input[type="radio"] { width: auto; margin: 0; }
47
+ form.add-source .schedule-when input[type="number"] { width: 80px; }
48
+ form.add-source .schedule-when select, form.add-source .schedule-when input[type="datetime-local"] { width: auto; }
42
49
  .approvals-nudge { border: 1px solid var(--warn); background: var(--warn-soft); color: var(--ink); border-radius: var(--radius); padding: 12px 14px; margin-bottom: 24px; font-size: 13px; }
43
50
  .env-desc { max-width: 340px; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
44
51
  form.add-source.env-inline { border: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 6px; max-width: 320px; }
@@ -1263,6 +1270,151 @@ ${consoleShellScript}
1263
1270
  });
1264
1271
  }
1265
1272
 
1273
+ // ---- Schedules tab: messages this agent receives on a timer (core/
1274
+ // scheduler.ts behind GET/POST .../schedules and POST /schedules/:id/
1275
+ // run|pause|resume|cancel). Reloaded every time the tab opens, and
1276
+ // every few seconds while a run is in progress. ----
1277
+
1278
+ var schedulesRefreshTimer = null;
1279
+
1280
+ function schedulesContentEl() {
1281
+ return detail.querySelector('#schedulesContent');
1282
+ }
1283
+
1284
+ function renderSchedulesTabPlaceholder() {
1285
+ return '<div id="schedulesContent"><p class="hint">Loading&hellip;</p></div>';
1286
+ }
1287
+
1288
+ var SCHEDULE_PILLS = {
1289
+ scheduled: 'good', running: 'accent', awaiting_approval: 'ask', paused: 'neutral', done: 'neutral', cancelled: 'neutral'
1290
+ };
1291
+ var SCHEDULE_LABELS = {
1292
+ scheduled: 'scheduled', running: 'running', awaiting_approval: 'waiting for approval', paused: 'paused', done: 'done', cancelled: 'cancelled'
1293
+ };
1294
+
1295
+ function scheduleText(s) {
1296
+ return s.run_at ? 'Once, ' + new Date(s.run_at).toLocaleString() : 'Every ' + escapeHtml(s.every);
1297
+ }
1298
+
1299
+ function renderScheduleRow(t) {
1300
+ var actions = [];
1301
+ if (t.status !== 'running' && t.status !== 'awaiting_approval' && t.status !== 'cancelled') actions.push(['run', 'Run now']);
1302
+ if (t.status === 'scheduled') actions.push(['pause', 'Pause']);
1303
+ if (t.status === 'paused') actions.push(['resume', 'Resume']);
1304
+ if (t.status !== 'cancelled' && t.status !== 'done') actions.push(['cancel', 'Cancel']);
1305
+ if (t.status === 'cancelled' || t.status === 'done') actions.push(['delete', 'Delete']);
1306
+ var last = t.last_run_at
1307
+ ? '<span class="le-pill ' + (t.last_status === 'ok' ? 'good' : 'bad') + '">' + escapeHtml(t.last_status) + '</span> ' +
1308
+ '<span class="hint">' + escapeHtml(new Date(t.last_run_at).toLocaleString()) + '</span>' +
1309
+ '<div class="schedule-result">' + escapeHtml(t.last_status === 'ok' ? (t.last_result || '') : (t.last_error || '')) + '</div>'
1310
+ : '<span class="hint">not run yet</span>';
1311
+ var history = (t.history || []).slice().reverse().map(function (h) {
1312
+ 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>' +
1313
+ '<div class="schedule-result">' + escapeHtml(h.status === 'ok' ? (h.result || '') : (h.error || '')) + '</div></li>';
1314
+ }).join('');
1315
+ return '<tr>' +
1316
+ '<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>' : '') +
1317
+ '<div class="schedule-result hint">' + escapeHtml(t.message) + '</div></td>' +
1318
+ '<td>' + scheduleText(t.schedule) + (t.next_run_at ? '<div class="hint">next ' + escapeHtml(new Date(t.next_run_at).toLocaleString()) + '</div>' : '') + '</td>' +
1319
+ '<td><span class="le-pill ' + (SCHEDULE_PILLS[t.status] || 'neutral') + '">' + escapeHtml(SCHEDULE_LABELS[t.status] || t.status) + '</span>' +
1320
+ (t.status === 'awaiting_approval' ? '<div class="hint"><a href="#" class="schedule-to-approvals">Decide in Permissions</a></div>' : '') + '</td>' +
1321
+ '<td style="min-width:200px">' + last + (history ? '<details><summary class="hint">History (' + (t.history || []).length + ')</summary><ul class="schedule-history">' + history + '</ul></details>' : '') + '</td>' +
1322
+ '<td><div style="display:flex;gap:6px;flex-wrap:wrap">' + actions.map(function (a) {
1323
+ return '<button type="button" class="schedule-action' + (a[0] === 'run' ? ' primary' : '') + (a[0] === 'cancel' || a[0] === 'delete' ? ' delete-btn' : '') + '" data-id="' + escapeHtml(t.task_id) + '" data-action="' + a[0] + '">' + a[1] + '</button>';
1324
+ }).join('') + '</div></td></tr>';
1325
+ }
1326
+
1327
+ function renderSchedulesHtml(tasks) {
1328
+ var active = tasks.filter(function (t) { return t.status !== 'cancelled' && t.status !== 'done'; });
1329
+ var list = tasks.length
1330
+ ? '<div class="le-table-wrap"><table><thead><tr><th>Task</th><th>When</th><th>Status</th><th>Last run</th><th></th></tr></thead><tbody>' + tasks.map(renderScheduleRow).join('') + '</tbody></table></div>'
1331
+ : '<p class="hint">Nothing scheduled yet.</p>';
1332
+ return '<p class="muted" style="margin:0 0 20px">Messages this agent receives on a timer, like a cron job. Each run works like an app calling it over HTTP: an approval goes wherever the Permissions tab sends it, and with nowhere set, a call that needs approval is denied. The agent can also schedule its own tasks when asked in Chat (&ldquo;remind me tomorrow at 9&rdquo;); they appear here too.</p>' +
1333
+ '<section><h3>Scheduled (' + active.length + ' active)</h3>' + list + '</section>' +
1334
+ '<section><h3>New schedule</h3>' +
1335
+ '<form class="add-source" id="scheduleForm" style="max-width:560px">' +
1336
+ '<label>Message <span class="hint">(what the agent receives each time, as if someone typed it)</span><textarea name="message" required placeholder="Summarize yesterday&#39;s support tickets and post the summary."></textarea></label>' +
1337
+ '<div class="body-field">When' +
1338
+ '<div class="schedule-when"><input type="radio" name="when" value="every" id="whenEvery" checked><label for="whenEvery">Every</label>' +
1339
+ '<input type="number" name="everyN" value="1" min="1" aria-label="How many">' +
1340
+ '<select name="everyUnit" aria-label="Unit"><option value="m">minutes</option><option value="h" selected>hours</option><option value="d">days</option></select></div>' +
1341
+ '<div class="schedule-when"><input type="radio" name="when" value="once" id="whenOnce"><label for="whenOnce">Once at</label>' +
1342
+ '<input type="datetime-local" name="runAt" aria-label="Date and time"></div>' +
1343
+ '</div>' +
1344
+ '<label>Name <span class="hint">(optional)</span><input name="task_name" placeholder="Daily ticket summary"></label>' +
1345
+ '<label class="http-tool-inline-label"><input type="checkbox" name="notify"> Send each result through this agent&#39;s notifier (set in the Permissions tab)</label>' +
1346
+ '<div style="display:flex;gap:8px"><button type="submit">Schedule</button></div>' +
1347
+ '<div id="scheduleResult" class="hint"></div>' +
1348
+ '</form></section>';
1349
+ }
1350
+
1351
+ function wireSchedulesHandlers(name) {
1352
+ var content = schedulesContentEl();
1353
+ if (!content) return;
1354
+ var actionBtns = content.querySelectorAll('.schedule-action');
1355
+ for (var i = 0; i < actionBtns.length; i++) {
1356
+ actionBtns[i].addEventListener('click', function (ev) {
1357
+ var btn = ev.currentTarget;
1358
+ var action = btn.dataset.action;
1359
+ if ((action === 'cancel' || action === 'delete') && btn.dataset.confirming !== '1') {
1360
+ btn.dataset.confirming = '1';
1361
+ btn.textContent = 'Click again to ' + action;
1362
+ return;
1363
+ }
1364
+ btn.disabled = true;
1365
+ var request = action === 'delete'
1366
+ ? fetchJsonRetryOnce('/schedules/' + encodeURIComponent(btn.dataset.id), { method: 'DELETE' })
1367
+ : fetchJsonRetryOnce('/schedules/' + encodeURIComponent(btn.dataset.id) + '/' + action, { method: 'POST' });
1368
+ request.then(function () { setTimeout(function () { loadSchedulesTab(name); }, action === 'run' ? 800 : 0); });
1369
+ });
1370
+ }
1371
+ var toApprovals = content.querySelectorAll('.schedule-to-approvals');
1372
+ for (var j = 0; j < toApprovals.length; j++) {
1373
+ toApprovals[j].addEventListener('click', function (ev) { ev.preventDefault(); switchTab('actauth'); });
1374
+ }
1375
+ var form = content.querySelector('#scheduleForm');
1376
+ var result = content.querySelector('#scheduleResult');
1377
+ form.addEventListener('submit', function (ev) {
1378
+ ev.preventDefault();
1379
+ var data = new FormData(form);
1380
+ var schedule;
1381
+ if (data.get('when') === 'once') {
1382
+ if (!data.get('runAt')) { result.textContent = 'Pick a date and time.'; return; }
1383
+ schedule = { run_at: new Date(data.get('runAt')).toISOString() };
1384
+ } else {
1385
+ schedule = { every: String(data.get('everyN')) + data.get('everyUnit') };
1386
+ }
1387
+ result.textContent = 'Scheduling' + String.fromCharCode(8230);
1388
+ fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/schedules', {
1389
+ method: 'POST', headers: { 'content-type': 'application/json' },
1390
+ body: JSON.stringify({ message: data.get('message'), schedule: schedule, task_name: data.get('task_name'), notify: data.get('notify') === 'on' }),
1391
+ }).then(function (r) {
1392
+ if (!r.ok) { result.textContent = r.body.error || 'Could not schedule'; return; }
1393
+ loadSchedulesTab(name);
1394
+ });
1395
+ });
1396
+ }
1397
+
1398
+ function loadSchedulesTab(name) {
1399
+ var content = schedulesContentEl();
1400
+ if (!content) return;
1401
+ if (schedulesRefreshTimer) { clearTimeout(schedulesRefreshTimer); schedulesRefreshTimer = null; }
1402
+ fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/schedules').then(function (r) {
1403
+ if (currentName !== name) return;
1404
+ if (!r.ok) throw new Error(r.body.error || 'request failed');
1405
+ var tasks = r.body.tasks || [];
1406
+ content.innerHTML = renderSchedulesHtml(tasks);
1407
+ wireSchedulesHandlers(name);
1408
+ leShell.setCount('schedules', tasks.filter(function (t) { return t.status !== 'cancelled' && t.status !== 'done'; }).length);
1409
+ if (tasks.some(function (t) { return t.status === 'running'; }) && currentTab === 'schedules') {
1410
+ schedulesRefreshTimer = setTimeout(function () { loadSchedulesTab(name); }, 3000);
1411
+ }
1412
+ }).catch(function (err) {
1413
+ if (currentName !== name) return;
1414
+ content.innerHTML = '<p class="error">Could not load schedules: ' + escapeHtml(err.message) + '</p>';
1415
+ });
1416
+ }
1417
+
1266
1418
  function actauthContentEl() {
1267
1419
  return detail.querySelector('#actauthContent');
1268
1420
  }
@@ -2737,7 +2889,7 @@ ${consoleShellScript}
2737
2889
 
2738
2890
  // ---- Tab switching + agent selection ----
2739
2891
 
2740
- var CONFIG_TABS = ['overview', 'tools', 'skills', 'abilities', 'actauth', 'env'];
2892
+ var CONFIG_TABS = ['overview', 'tools', 'skills', 'abilities', 'actauth', 'schedules', 'env'];
2741
2893
 
2742
2894
  function switchTab(tab) {
2743
2895
  if (CONFIG_TABS.indexOf(tab) === -1) tab = 'overview';
@@ -2765,6 +2917,7 @@ ${consoleShellScript}
2765
2917
  if (tab === 'abilities' && abilitiesLoadedFor !== currentName) {
2766
2918
  loadAbilitiesTab(currentName);
2767
2919
  }
2920
+ if (tab === 'schedules') loadSchedulesTab(currentName);
2768
2921
  }
2769
2922
 
2770
2923
  // Refreshes just Overview's own summary — used after anything that
@@ -2861,6 +3014,7 @@ ${consoleShellScript}
2861
3014
  '<div class="tab-panel" data-tab-panel="actauth">' + renderActauthTabPlaceholder() + '</div>' +
2862
3015
  '<div class="tab-panel" data-tab-panel="env">' + renderEnvTabPlaceholder() + '</div>' +
2863
3016
  '<div class="tab-panel" data-tab-panel="abilities">' + renderAbilitiesTabPlaceholder() + '</div>' +
3017
+ '<div class="tab-panel" data-tab-panel="schedules">' + renderSchedulesTabPlaceholder() + '</div>' +
2864
3018
  '</div></div>';
2865
3019
 
2866
3020
  wireSkillsHandlers(cfg.name);
@@ -42,7 +42,7 @@ export const consoleShellScript = `<script>
42
42
  var toSet = {};
43
43
  var TABS = [
44
44
  ['chat', 'Chat'], ['overview', 'Overview'], ['abilities', 'Abilities'], ['tools', 'Tools'],
45
- ['skills', 'Skills'], ['actauth', 'Permissions'], ['env', 'Environment']
45
+ ['skills', 'Skills'], ['actauth', 'Permissions'], ['schedules', 'Schedules'], ['env', 'Environment']
46
46
  ];
47
47
 
48
48
  function esc(s) {
@@ -1,6 +1,7 @@
1
1
  import type { AgentConfig } from '../core/agent-config.js';
2
2
  import type { CheckpointStore } from '../core/durable-approvals.js';
3
3
  import { type DeliveryResult } from '../core/http-notify-triggers/webhook.js';
4
+ import type { HttpNotifierConfig } from '../core/agent-config.js';
4
5
  import { type NotifierFileChannel } from '../core/notifier-file.js';
5
6
  import { type SlackChannel } from '../core/slack-interactions.js';
6
7
  /** The deployment-wide default webhook (LOOPENGINE_DEFAULT_WEBHOOK_URL /
@@ -25,7 +26,7 @@ export declare function describeAgentNotifier(config: AgentConfig, defaultWebhoo
25
26
  manifestTemplate: Record<string, unknown>;
26
27
  };
27
28
  deliveries: import("../core/notifier-deliveries.js").NotifierDelivery[];
28
- channel?: import("../core/agent-config.js").HttpNotifierConfig["channel"];
29
+ channel?: HttpNotifierConfig["channel"];
29
30
  events?: string[];
30
31
  file?: import("../core/notifier-file.js").NotifierFile;
31
32
  missingSecret?: string;
@@ -49,6 +50,11 @@ export declare function listAgentSlackChannels(config: AgentConfig, botToken?: s
49
50
  /** Removes notifier.yml; the stored secret is left alone, so turning the
50
51
  * notifier back on doesn't need it re-entered. */
51
52
  export declare function removeAgentNotifier(config: AgentConfig): void;
53
+ /** Posts a plain announcement through the agent's notifier — a scheduled
54
+ * task's result, for one — regardless of which events it subscribed to.
55
+ * A webhook gets a signed `run_finish` payload with `data` merged in;
56
+ * Slack and Lark get `text`. Does nothing when no notifier applies. */
57
+ export declare function announceToNotifier(config: AgentConfig, text: string, data: Record<string, unknown>, defaultWebhook?: DefaultWebhook): boolean;
52
58
  /** Sends a test through whichever notifier applies to this agent —
53
59
  * webhook or Slack; reports what the receiver answered. */
54
60
  export declare function testAgentNotifier(config: AgentConfig, defaultWebhook?: DefaultWebhook): Promise<DeliveryResult & {
@@ -2,7 +2,9 @@ import { agentDirFor, createAgentEnv } from '../core/agent-env.js';
2
2
  import { decidePath } from '../core/approval-links.js';
3
3
  import { describeNotifier } from '../core/http-notifier.js';
4
4
  import { sendTestSlackMessage } from '../core/http-notify-triggers/slack.js';
5
- import { sendTestWebhook } from '../core/http-notify-triggers/webhook.js';
5
+ import { postLifecycleWebhook, sendTestWebhook } from '../core/http-notify-triggers/webhook.js';
6
+ import { SlackNotifier } from '../core/http-notify-triggers/slack.js';
7
+ import { LarkNotifier } from '../core/http-notify-triggers/lark.js';
6
8
  import { listDeliveries } from '../core/notifier-deliveries.js';
7
9
  import { NOTIFIER_SECRET_VARS, NOTIFIER_SLACK_SIGNING_SECRET_VAR, notifierConfigFromFile, readNotifierFile, removeNotifierFile, writeNotifierFile } from '../core/notifier-file.js';
8
10
  import { listSlackChannels, slackAppManifest } from '../core/slack-interactions.js';
@@ -65,6 +67,49 @@ export async function listAgentSlackChannels(config, botToken) {
65
67
  export function removeAgentNotifier(config) {
66
68
  removeNotifierFile(agentDirFor(config));
67
69
  }
70
+ /** The notifier settings that apply to an agent: its code httpNotifier,
71
+ * else its notifier.yml (once its secret is set), else the
72
+ * deployment-wide default webhook. */
73
+ function activeNotifierTarget(config, defaultWebhook) {
74
+ if (config.httpNotifier)
75
+ return config.httpNotifier;
76
+ const dir = agentDirFor(config);
77
+ const file = readNotifierFile(dir);
78
+ if (file) {
79
+ const { config: fromFile } = notifierConfigFromFile(file, createAgentEnv(config.name, dir));
80
+ if (fromFile)
81
+ return fromFile;
82
+ }
83
+ return defaultWebhook ? { channel: 'webhook', config: { webhookUrl: defaultWebhook.webhookUrl, webhookSecret: defaultWebhook.webhookSecret }, events: ['run_finish'] } : undefined;
84
+ }
85
+ /** Posts a plain announcement through the agent's notifier — a scheduled
86
+ * task's result, for one — regardless of which events it subscribed to.
87
+ * A webhook gets a signed `run_finish` payload with `data` merged in;
88
+ * Slack and Lark get `text`. Does nothing when no notifier applies. */
89
+ export function announceToNotifier(config, text, data, defaultWebhook) {
90
+ let target;
91
+ try {
92
+ target = activeNotifierTarget(config, defaultWebhook);
93
+ }
94
+ catch {
95
+ return false;
96
+ }
97
+ if (!target)
98
+ return false;
99
+ switch (target.channel) {
100
+ case 'webhook':
101
+ postLifecycleWebhook(target.config.webhookUrl, target.config.webhookSecret, 'run_finish', { agent: config.name, text, ...data });
102
+ return true;
103
+ case 'slack':
104
+ new SlackNotifier({ botToken: target.config.botToken, channelId: target.config.channelId }).postMessage(text);
105
+ return true;
106
+ case 'lark':
107
+ new LarkNotifier({ appId: target.config.appId, appSecret: target.config.appSecret, chatId: target.config.chatId }).postMessage(text);
108
+ return true;
109
+ default:
110
+ return false;
111
+ }
112
+ }
68
113
  /** Sends a test through whichever notifier applies to this agent —
69
114
  * webhook or Slack; reports what the receiver answered. */
70
115
  export async function testAgentNotifier(config, defaultWebhook) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopengine",
3
- "version": "0.1.69",
3
+ "version": "0.1.70",
4
4
  "type": "module",
5
5
  "description": "Runtime for tool-using AI agents with a transparent ReAct loop.",
6
6
  "license": "MIT",
@@ -52,6 +52,8 @@
52
52
  "dist/core/approval-links.d.ts",
53
53
  "dist/core/slack-interactions.js",
54
54
  "dist/core/slack-interactions.d.ts",
55
+ "dist/core/scheduler.js",
56
+ "dist/core/scheduler.d.ts",
55
57
  "dist/core/session-store.js",
56
58
  "dist/core/session-store.d.ts",
57
59
  "dist/core/vector-index.js",
@@ -102,7 +104,7 @@
102
104
  "LICENSE"
103
105
  ],
104
106
  "scripts": {
105
- "build": "tsc; s=$?; chmod +x dist/bin/cli.js; cp -r core/system-skills dist/core/system-skills; tsx scripts/write-dist-package-json.ts; exit $s",
107
+ "build": "tsc; s=$?; chmod +x dist/bin/cli.js; rm -rf dist/core/system-skills; cp -r core/system-skills dist/core/system-skills; tsx scripts/write-dist-package-json.ts; exit $s",
106
108
  "prepublishOnly": "npm run build",
107
109
  "start": "node --env-file-if-exists=.env dist/adapters/http.js",
108
110
  "test": "vitest run"