create-loopengine 0.2.1 → 0.3.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-loopengine",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "description": "Scaffold a new loopengine agent project — a runnable HTTP server with one example agent, in your own repo.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -122,6 +122,7 @@ import {
122
122
  TRUSTED_PROXY_IDENTITY_HEADER,
123
123
  NotifierFileError,
124
124
  verifyDecisionToken,
125
+ decidePath,
125
126
  renderDecidePage,
126
127
  renderDecideGonePage,
127
128
  renderDecideInvalidPage,
@@ -139,12 +140,22 @@ import {
139
140
  pauseScheduledTask,
140
141
  resumeScheduledTask,
141
142
  runScheduledTaskNow,
143
+ setScheduledTaskNotify,
142
144
  type ScheduledTask,
143
145
  type ScheduledTaskRun,
144
146
  listAgentPending,
145
147
  listAgentSlackChannels,
146
148
  slackCredentialsFor,
147
- slackConversationsOn,
149
+ agentTelegramWebhookInfo,
150
+ clickAllowed,
151
+ connectionPublicUrl,
152
+ conversationsOn,
153
+ jobAllowed,
154
+ jobsOn,
155
+ listAgentTelegramPeople,
156
+ setupAgentTelegramWebhook,
157
+ telegramCredentialsFor,
158
+ type TwoWayApp,
148
159
  slackClickAllowed,
149
160
  agentApprovers,
150
161
  setAgentApprovers,
@@ -166,6 +177,28 @@ import {
166
177
  firstSighting,
167
178
  handleInboundMessage,
168
179
  createSlackConnector,
180
+ outcomeText,
181
+ handleJobMessage,
182
+ isJobMessage,
183
+ settleJobThread,
184
+ type JobDeps,
185
+ type JobTurn,
186
+ findJobThreadBySession,
187
+ findThread,
188
+ type InboundMessage,
189
+ type MessagingConnector,
190
+ formatArgs,
191
+ answerTelegramClick,
192
+ createTelegramConnector,
193
+ editTelegramMessage,
194
+ parseTelegramUpdate,
195
+ readTelegramThread,
196
+ recordTelegramMessage,
197
+ telegramBotUsername,
198
+ telegramEscape,
199
+ telegramRootOf,
200
+ verifyTelegramRequest,
201
+ type TelegramUpdate,
169
202
  parseSlackEvent,
170
203
  removeAgentNotifier,
171
204
  saveAgentNotifier,
@@ -201,9 +234,11 @@ import {
201
234
  forgetWorkflow,
202
235
  getPlanJob,
203
236
  listAgentWorkflows,
237
+ migrateAgentWorkflows,
204
238
  planWorkflow,
205
- runWorkflowNow,
239
+ prepareWorkflowRun,
206
240
  startPlanJob,
241
+ updateWorkflow,
207
242
  type WorkflowAgentInfo,
208
243
  type WorkflowMcpDeps,
209
244
  workflowPageHtml,
@@ -273,6 +308,39 @@ import {
273
308
  createScheduleTools,
274
309
  type PendingQuestion,
275
310
  WebhookNotifier,
311
+ publicBaseUrl,
312
+ // Workflows (skills with a start message), their runs, run callbacks
313
+ // and webhook triggers.
314
+ WorkflowParamsError,
315
+ readWorkflow,
316
+ readWorkflows,
317
+ validateParams,
318
+ workflowRunMessage,
319
+ WorkflowRunner,
320
+ getRun,
321
+ getRunStore,
322
+ listRuns,
323
+ type WorkflowRun,
324
+ CallbackUrlError,
325
+ callbackSecret,
326
+ checkCallbackUrl,
327
+ deliverCallback,
328
+ runCallbackPayload,
329
+ type RunCallbackEvent,
330
+ TriggerError,
331
+ createTrigger,
332
+ deliveryId,
333
+ findTriggerByToken,
334
+ getTriggerStore,
335
+ listTriggers,
336
+ paramsFromPayload,
337
+ parsePayload,
338
+ passesFilter,
339
+ publicTrigger,
340
+ recordTriggerDelivery as recordDelivery,
341
+ rotateTrigger,
342
+ updateTrigger,
343
+ verifySenderSignature,
276
344
  } from 'loopengine'
277
345
  import { SkillGarden } from 'loopengine'
278
346
  import type { Decision, PendingApproval } from 'actauth'
@@ -369,6 +437,27 @@ function readRawBody(req: IncomingMessage): Promise<string> {
369
437
  })
370
438
  }
371
439
 
440
+ /** The raw request body, refused past `maxBytes` (null then). */
441
+ function readLimitedRawBuffer(req: IncomingMessage, maxBytes: number): Promise<Buffer | null> {
442
+ return new Promise((resolve, reject) => {
443
+ const chunks: Buffer[] = []
444
+ let size = 0
445
+ let over = false
446
+ req.on('data', (chunk: Buffer) => {
447
+ if (over) return
448
+ size += chunk.length
449
+ if (size > maxBytes) {
450
+ over = true
451
+ resolve(null)
452
+ return
453
+ }
454
+ chunks.push(chunk)
455
+ })
456
+ req.on('end', () => { if (!over) resolve(Buffer.concat(chunks)) })
457
+ req.on('error', reject)
458
+ })
459
+ }
460
+
372
461
  /** readJsonBody with a size cap, for uploads: a body past `maxBytes` is
373
462
  * refused before it's all read. */
374
463
  function readLimitedJsonBody(req: IncomingMessage, maxBytes: number): Promise<Record<string, unknown>> {
@@ -563,7 +652,7 @@ async function describeAgent(entry: RegistryEntry): Promise<Record<string, unkno
563
652
  // createAskUserTool() with no onPending, purely to describe its
564
653
  // schema here — never executed, so the console-prompt fallback its
565
654
  // own doc comment mentions never applies to this call.
566
- systemTools: [...systemTools, createAskUserTool({ agent: config.name }), ...(config.selfScheduling === false ? [] : createScheduleTools(config.name))].map(describeTool),
655
+ systemTools: [...systemTools, createAskUserTool({ agent: config.name }), ...(config.selfScheduling === false ? [] : createScheduleTools(config.name, { agentDir: agentDirFor(config) }))].map(describeTool),
567
656
  localTools: localTools.map((t) => ({ ...describeTool(t), httpToolEditable: editableHttpToolNames.has(t.name) })),
568
657
  agentAsTools: agentAsTools.map(describeTool),
569
658
  gatewayTools: gatewayTools.map(describeTool),
@@ -1422,6 +1511,11 @@ async function handleWorkflows(req: IncomingMessage, res: ServerResponse, pathna
1422
1511
  return entry ? describeAgentNotifier(entry.config, defaultWebhook).source !== 'none' : false
1423
1512
  },
1424
1513
  })
1514
+ // The planned webhook's URL, shown once on the done screen.
1515
+ if (created.webhook) {
1516
+ const { token, ...webhook } = created.webhook
1517
+ return json(200, { ...created, webhook: { ...webhook, url: `${requestBaseUrl(req)}/hooks/workflows/${token}` } })
1518
+ }
1425
1519
  json(200, created)
1426
1520
  // New tools in an agent that was already running load with a restart,
1427
1521
  // as after installing an ability from its tab.
@@ -1437,7 +1531,28 @@ async function handleWorkflows(req: IncomingMessage, res: ServerResponse, pathna
1437
1531
  }
1438
1532
  }
1439
1533
 
1440
- // An agent's workflows (its Overview): list, run now, forget.
1534
+ // How long POST .../workflows/:id/run with "wait": true waits for the run
1535
+ // before answering 202 with its runId (look it up later with GET
1536
+ // /agents/:name/runs/:runId); under the 90 s an ingress allows.
1537
+ const WORKFLOW_WAIT_MS = Number(process.env.LOOPENGINE_WORKFLOW_WAIT_MS) || 85_000
1538
+
1539
+ // An agent's workflows (its Overview, and apps over the API): list, run
1540
+ // now with parameters, edit how runs start (PUT), forget.
1541
+ //
1542
+ // POST /agents/:name/workflows/:id/run
1543
+ // { "params": {…}, "wait": true, "notify": true, "callback_url": "https://…" }
1544
+ //
1545
+ // "notify" sends the result through the agent's messaging connection when
1546
+ // the run ends; "callback_url" gets the run's record posted to it when it
1547
+ // ends or stops for a decision, signed (core/run-callbacks.ts). Both are
1548
+ // optional, and work with or without "wait".
1549
+ //
1550
+ // starts a one-off run. Without "wait" it answers 202 { runId } straight
1551
+ // away. With it, 200 { runId, text } once the run finishes, 202 { runId,
1552
+ // pending: true } if it stops for an approval or a question, or 202
1553
+ // { runId, running: true } if it's still going after WORKFLOW_WAIT_MS.
1554
+ // A failed run answers 502 { runId, error }. Wrong or missing params are
1555
+ // a 400 before anything runs.
1441
1556
  async function handleAgentWorkflows(req: IncomingMessage, res: ServerResponse, agentName: string, id: string | undefined, action: string | undefined): Promise<void> {
1442
1557
  const json = (status: number, body: unknown): void => {
1443
1558
  res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(JSON.stringify(body))
@@ -1446,15 +1561,46 @@ async function handleAgentWorkflows(req: IncomingMessage, res: ServerResponse, a
1446
1561
  const dir = agentDir(agentName)
1447
1562
  try {
1448
1563
  if (!id && req.method === 'GET') return json(200, { workflows: await listAgentWorkflows(agentName, dir) })
1449
- if (id && action === 'run' && req.method === 'POST') return json(200, await runWorkflowNow(agentName, dir, id))
1450
- if (id && !action && req.method === 'DELETE') {
1451
- forgetWorkflow(dir, id)
1564
+ if (id && action === 'runs' && req.method === 'GET') return json(200, { runs: await listRuns(agentName, { workflowId: id, limit: 50 }) })
1565
+ if (id && action === 'run' && req.method === 'POST') {
1566
+ const body = await readJsonBody(req)
1567
+ if (body.notify !== undefined && typeof body.notify !== 'boolean') return json(400, { error: 'notify must be true or false' })
1568
+ let callbackUrl: string | undefined
1569
+ if (body.callback_url !== undefined && body.callback_url !== null && body.callback_url !== '') {
1570
+ try {
1571
+ callbackUrl = checkCallbackUrl(body.callback_url)
1572
+ } catch (err) {
1573
+ return json(400, { error: err instanceof CallbackUrlError ? err.message : String(err) })
1574
+ }
1575
+ }
1576
+ const prepared = await prepareWorkflowRun(agentName, dir, id, body.params)
1577
+ const run = await workflowRunner.start({
1578
+ agent: agentName, workflow_id: id, params: prepared.params, source: body.source === 'console' ? 'console' : 'api', message: prepared.message,
1579
+ ...(body.notify === true ? { notify: true } : {}), ...(callbackUrl ? { callback_url: callbackUrl } : {}),
1580
+ })
1581
+ if (body.wait !== true) return json(202, { runId: run.run_id })
1582
+ const deadline = Date.now() + WORKFLOW_WAIT_MS
1583
+ for (;;) {
1584
+ const current = await getRun(agentName, run.run_id)
1585
+ if (current?.status === 'awaiting_approval') return json(202, { runId: run.run_id, pending: true })
1586
+ if (current?.status === 'done') return json(200, { runId: run.run_id, text: current.result ?? '' })
1587
+ if (current?.status === 'error') return json(502, { runId: run.run_id, error: current.error ?? 'The run failed.' })
1588
+ if (Date.now() >= deadline) return json(202, { runId: run.run_id, running: true })
1589
+ await new Promise((r) => setTimeout(r, 300))
1590
+ }
1591
+ }
1592
+ if (id && !action && req.method === 'PUT') {
1593
+ updateWorkflow(dir, id, await readJsonBody(req))
1452
1594
  return json(200, { workflows: await listAgentWorkflows(agentName, dir) })
1453
1595
  }
1596
+ if (id && !action && req.method === 'DELETE') {
1597
+ const { cancelled } = await forgetWorkflow(agentName, dir, id)
1598
+ return json(200, { cancelledSchedules: cancelled, workflows: await listAgentWorkflows(agentName, dir) })
1599
+ }
1454
1600
  return json(404, { error: 'not found' })
1455
1601
  } catch (err) {
1456
1602
  const message = err instanceof Error ? err.message : String(err)
1457
- return json(err instanceof WorkflowError ? 400 : 500, { error: message })
1603
+ return json(err instanceof WorkflowError || err instanceof WorkflowParamsError ? 400 : 500, { error: message })
1458
1604
  }
1459
1605
  }
1460
1606
 
@@ -1743,34 +1889,71 @@ function handleEnvGet(res: ServerResponse, agentName: string): void {
1743
1889
  // durable approval, and with no notifier at all it's denied rather than
1744
1890
  // left waiting on nobody — a scheduled run has no one live in it. ----
1745
1891
 
1746
- const NO_HUMAN_ANSWER = 'No human is available to answer — this is an unattended scheduled run. Use your own best judgement and proceed without waiting for a response.'
1892
+ const NO_HUMAN_ANSWER = 'No human is available to answer — this is an unattended run. Use your own best judgement and proceed without waiting for a response.'
1747
1893
 
1748
- async function fireScheduledTask(task: ScheduledTask, rawSessionId: string): Promise<{ kind: 'done'; text: string } | { kind: 'pending'; pendingIds: string[] }> {
1749
- const entry = getEntry(task.agent)
1750
- if (!entry) throw new Error(`no agent named "${task.agent}" in this project`)
1894
+ // One unattended agent turn: a scheduled task firing, or a workflow run
1895
+ // started by hand or by an app (core/workflow-runs.ts).
1896
+ async function executeUnattendedRun(agent: string, message: string, rawSessionId: string): Promise<{ kind: 'done'; text: string } | { kind: 'pending'; pendingIds: string[] }> {
1897
+ const entry = getEntry(agent)
1898
+ if (!entry) throw new Error(`no agent named "${agent}" in this project`)
1751
1899
  const tenant = 'default'
1752
1900
  const environment = process.env.LOOPENGINE_ENV ?? 'production'
1753
- const outcome = await sessions.withSession(`${tenant}:${environment}:${task.agent}:${rawSessionId}`, async (history) => {
1754
- const result = await runAgent(entry.config, entry.createModelCall(), task.message, history, {
1901
+ const outcome = await sessions.withSession(`${tenant}:${environment}:${agent}:${rawSessionId}`, async (history) => {
1902
+ const result = await runAgent(entry.config, entry.createModelCall(), message, history, {
1755
1903
  tenant,
1756
1904
  sessionId: rawSessionId,
1757
1905
  channel: 'http',
1758
1906
  approver: defaultHttpWebhookNotifier ?? { requestApproval: async () => false },
1759
1907
  questionHandler: defaultHttpWebhookNotifier ?? { requestQuestion: async () => NO_HUMAN_ANSWER },
1760
1908
  })
1761
- if (result.pending) await createCheckpointFromPending(task.agent, tenant, rawSessionId, result.pending)
1909
+ if (result.pending) await createCheckpointFromPending(agent, tenant, rawSessionId, result.pending)
1762
1910
  return { newMessages: result.newMessages, result: { text: result.text, stopReason: result.stopReason, pendingIds: result.pending?.outstanding.map((o) => o.pendingId) } }
1763
1911
  })
1764
1912
  if (outcome.pendingIds?.length) return { kind: 'pending', pendingIds: outcome.pendingIds }
1765
- // run-agent's "you denied" wording is for someone live in a chat; in a
1766
- // scheduled run nobody denied anything, so say what actually happened.
1913
+ // run-agent's "you denied" wording is for someone live in a chat; in an
1914
+ // unattended run nobody denied anything, so say what actually happened.
1767
1915
  if (outcome.stopReason === 'denied') {
1768
1916
  const tools = /denied: (.+?)\. Send/.exec(outcome.text)?.[1] ?? 'a tool call'
1769
- throw new Error(`Not allowed: ${tools}. A call that needs approval is denied in a scheduled run unless the Permissions tab sends approval requests somewhere (or a rule denies it outright).`)
1917
+ throw new Error(`Not allowed: ${tools}. A call that needs approval is denied in an unattended run unless the Permissions tab sends approval requests somewhere (or a rule denies it outright).`)
1770
1918
  }
1771
1919
  return { kind: 'done', text: outcome.text }
1772
1920
  }
1773
1921
 
1922
+ // A run started over the API with "notify" or "callback_url" reports
1923
+ // when it ends (and, to its callback, when it stops for a decision): its
1924
+ // result through the agent's notifier, and its record to the URL.
1925
+ async function reportWorkflowRun(run: WorkflowRun): Promise<void> {
1926
+ const event: RunCallbackEvent | undefined = run.status === 'done' ? 'run.done' : run.status === 'error' ? 'run.failed' : run.status === 'awaiting_approval' ? 'run.awaiting_approval' : undefined
1927
+ if (!event) return
1928
+ const entry = getEntry(run.agent)
1929
+ if (run.notify && entry && event !== 'run.awaiting_approval') {
1930
+ const text = run.status === 'done' ? `Workflow ${run.workflow_id} for ${run.agent} ran:\n${run.result ?? ''}` : `Workflow ${run.workflow_id} for ${run.agent} failed: ${run.error ?? 'unknown error'}`
1931
+ announceToNotifier(entry.config, text, { runId: run.run_id, workflowId: run.workflow_id, status: run.status, result: run.result ?? null, error: run.error ?? null }, defaultWebhook)
1932
+ }
1933
+ if (run.callback_url) {
1934
+ const delivery = await deliverCallback(run.callback_url, event, runCallbackPayload(event, run))
1935
+ await getRunStore().update(run.run_id, (r) => r && { ...r, callbacks: [...(r.callbacks ?? []), delivery].slice(-10) })
1936
+ }
1937
+ }
1938
+
1939
+ // Workflow runs, whatever starts them, each recorded apart from schedules.
1940
+ const workflowRunner = new WorkflowRunner({ execute: executeUnattendedRun, onChange: reportWorkflowRun })
1941
+
1942
+ // A task that runs a workflow is also recorded as a run of that workflow,
1943
+ // so the workflow's Recent runs shows its scheduled runs too.
1944
+ function fireScheduledTask(task: ScheduledTask, rawSessionId: string): ReturnType<typeof executeUnattendedRun> {
1945
+ if (!task.workflow_id) return executeUnattendedRun(task.agent, task.message, rawSessionId)
1946
+ return workflowRunner.runForSchedule({
1947
+ agent: task.agent,
1948
+ workflow_id: task.workflow_id,
1949
+ params: task.params,
1950
+ task_id: task.task_id,
1951
+ task_name: task.task_name,
1952
+ message: task.message,
1953
+ session_id: rawSessionId,
1954
+ })
1955
+ }
1956
+
1774
1957
  // A task with `notify` on reports each run through its agent's notifier.
1775
1958
  function announceScheduledRun(task: ScheduledTask, run: ScheduledTaskRun): void {
1776
1959
  if (!task.notify) return
@@ -1781,24 +1964,71 @@ function announceScheduledRun(task: ScheduledTask, run: ScheduledTaskRun): void
1781
1964
  announceToNotifier(entry.config, text, { scheduledTaskId: task.task_id, taskName: task.task_name ?? null, status: run.status, result: run.result ?? null, error: run.error ?? null }, defaultWebhook)
1782
1965
  }
1783
1966
 
1784
- const scheduler = new Scheduler({ fire: fireScheduledTask, onSettled: announceScheduledRun })
1967
+ // A workflow task's message is worked out at run time from the workflow
1968
+ // as it is then: its start message and steps, with the task's params
1969
+ // (which must still fit its parameters) filled in.
1970
+ const scheduler = new Scheduler({
1971
+ fire: fireScheduledTask,
1972
+ resolveWorkflowMessage: (agent, workflowId, params) => {
1973
+ const workflow = getEntry(agent) ? readWorkflow(agentDir(agent), workflowId) : undefined
1974
+ return workflow && workflowRunMessage(workflow, validateParams(workflow, params))
1975
+ },
1976
+ onSettled: announceScheduledRun,
1977
+ })
1785
1978
 
1786
1979
  function scheduleJson(res: ServerResponse, status: number, body: unknown): void {
1787
1980
  res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify(body))
1788
1981
  }
1789
1982
 
1983
+ // A workflow's start message with a task's params in its $name
1984
+ // placeholders (a declared one not given becomes empty), for the
1985
+ // Schedules tab; runs themselves use workflowRunMessage.
1986
+ function fillStartMessage(text: string, params: Record<string, string>, declared: string[]): string {
1987
+ return text.replace(/\$([a-z_][a-z0-9_]*)/g, (match, name: string) =>
1988
+ Object.prototype.hasOwnProperty.call(params, name) ? params[name]! : declared.includes(name) ? '' : match)
1989
+ }
1990
+
1991
+ // The agent's tasks, a workflow task with its workflow's name and current
1992
+ // start message (or `workflow_missing` once the workflow is gone), and
1993
+ // the agent's workflows, for the New schedule form's choice.
1790
1994
  async function handleSchedulesGet(res: ServerResponse, agentName: string): Promise<void> {
1791
1995
  if (!getEntry(agentName)) return scheduleJson(res, 404, { error: `unknown agent '${agentName}'` })
1792
- scheduleJson(res, 200, { tasks: await listScheduledTasks({ agent: agentName }), time_zone: workspaceTimeZone() })
1996
+ await migrateAgentWorkflows(agentName, agentDir(agentName))
1997
+ const workflows = readWorkflows(agentDir(agentName))
1998
+ const tasks = (await listScheduledTasks({ agent: agentName })).map((t) => {
1999
+ if (!t.workflow_id) return t
2000
+ const workflow = workflows.find((w) => w.id === t.workflow_id)
2001
+ // Its start message as this task's runs see it, parameters filled in.
2002
+ const startMessage = workflow && fillStartMessage(workflow.startMessage, t.params ?? {}, Object.keys(workflow.parameters))
2003
+ return workflow ? { ...t, workflow: { id: workflow.id, title: workflow.title, start_message: startMessage } } : { ...t, workflow_missing: true }
2004
+ })
2005
+ scheduleJson(res, 200, {
2006
+ tasks,
2007
+ workflows: workflows.map((w) => ({ id: w.id, title: w.title, description: w.description, start_message: w.startMessage, parameters: w.parameters })),
2008
+ time_zone: workspaceTimeZone(),
2009
+ })
1793
2010
  }
1794
2011
 
1795
2012
  async function handleSchedulesPost(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
1796
2013
  if (!getEntry(agentName)) return scheduleJson(res, 404, { error: `unknown agent '${agentName}'` })
1797
2014
  const body = await readJsonBody(req)
2015
+ const workflowId = typeof body.workflow_id === 'string' && body.workflow_id ? body.workflow_id : undefined
2016
+ let params: Record<string, string> | undefined
2017
+ if (workflowId) {
2018
+ const workflow = readWorkflow(agentDir(agentName), workflowId)
2019
+ if (!workflow) return scheduleJson(res, 400, { error: `${agentName} has no workflow "${workflowId}"` })
2020
+ try {
2021
+ params = validateParams(workflow, body.params)
2022
+ } catch (err) {
2023
+ return scheduleJson(res, 400, { error: err instanceof Error ? err.message : String(err) })
2024
+ }
2025
+ }
1798
2026
  try {
1799
2027
  const task = await createScheduledTask({
1800
2028
  agent: agentName,
1801
- message: String(body.message ?? ''),
2029
+ message: typeof body.message === 'string' ? body.message : undefined,
2030
+ workflow_id: workflowId,
2031
+ ...(params && Object.keys(params).length ? { params } : {}),
1802
2032
  schedule: (body.schedule ?? {}) as { run_at?: unknown; every?: unknown; start_at?: unknown; at?: unknown; weekday?: unknown },
1803
2033
  task_name: typeof body.task_name === 'string' ? body.task_name : undefined,
1804
2034
  notify: body.notify === true,
@@ -1942,7 +2172,7 @@ async function handleApproversPut(req: IncomingMessage, res: ServerResponse, age
1942
2172
  }
1943
2173
  const body = await readJsonBody(req)
1944
2174
  try {
1945
- const approvers = setAgentApprovers(entry.config, body.approvers)
2175
+ const approvers = setAgentApprovers(entry.config, body.approvers, body.app === 'telegram' ? 'telegram' : 'slack')
1946
2176
  res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ approvers }))
1947
2177
  } catch (err) {
1948
2178
  const status = err instanceof NotifierFileError ? 400 : 500
@@ -1950,6 +2180,26 @@ async function handleApproversPut(req: IncomingMessage, res: ServerResponse, age
1950
2180
  }
1951
2181
  }
1952
2182
 
2183
+ // Messaging tab → Telegram: where the bot's updates go (GET), and
2184
+ // pointing them at this server (POST), which turns on buttons and
2185
+ // replies. Setting it needs the admin login, like saving a secret.
2186
+ async function handleTelegramWebhookSetup(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
2187
+ const json = (status: number, body: unknown): void => {
2188
+ res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(JSON.stringify(body))
2189
+ }
2190
+ const entry = getEntry(agentName)
2191
+ if (!entry) return json(404, { error: `unknown agent '${agentName}'` })
2192
+ try {
2193
+ if (req.method === 'GET') return json(200, await agentTelegramWebhookInfo(entry.config))
2194
+ if (!secretsProtected) return json(403, { error: 'Setting up the Telegram webhook needs LOOPENGINE_ADMIN_AUTH set on the server.' })
2195
+ const forwardedProto = String(req.headers['x-forwarded-proto'] ?? '').split(',')[0]
2196
+ const base = `${forwardedProto || ((req.socket as { encrypted?: boolean }).encrypted ? 'https' : 'http')}://${String(req.headers['x-forwarded-host'] ?? req.headers.host ?? 'localhost')}`
2197
+ return json(200, await setupAgentTelegramWebhook(entry.config, base))
2198
+ } catch (err) {
2199
+ return json(400, { error: err instanceof Error ? err.message : String(err) })
2200
+ }
2201
+ }
2202
+
1953
2203
  async function handleSlackMembers(res: ServerResponse, agentName: string): Promise<void> {
1954
2204
  const entry = getEntry(agentName)
1955
2205
  if (!entry) {
@@ -3170,6 +3420,7 @@ async function respondAfterResolutionNow(res: ServerResponse, finalCheckpoint: T
3170
3420
  }
3171
3421
  const environment = process.env.LOOPENGINE_ENV ?? 'production'
3172
3422
  const storageSessionId = `${finalCheckpoint.tenant}:${environment}:${finalCheckpoint.agent}:${finalCheckpoint.sessionId}`
3423
+ const fromJobThread = Boolean(findJobThreadBySession(agentDirFor(entry.config), finalCheckpoint.sessionId))
3173
3424
 
3174
3425
  const responseBody = await sessions.withSession(storageSessionId, async (history) => {
3175
3426
  // Same channel/approver/questionHandler fallback the original
@@ -3197,8 +3448,9 @@ async function respondAfterResolutionNow(res: ServerResponse, finalCheckpoint: T
3197
3448
  tenant: finalCheckpoint.tenant,
3198
3449
  sessionId: finalCheckpoint.sessionId,
3199
3450
  channel: 'http',
3200
- approver: defaultHttpWebhookNotifier ?? createTrackedApprover(finalCheckpoint.sessionId),
3201
- questionHandler: defaultHttpWebhookNotifier,
3451
+ // A job thread's run keeps asking in its thread (runJobTurn).
3452
+ approver: fromJobThread ? threadApprover : defaultHttpWebhookNotifier ?? createTrackedApprover(finalCheckpoint.sessionId),
3453
+ questionHandler: fromJobThread ? threadQuestions : defaultHttpWebhookNotifier,
3202
3454
  onQuestionPending: () => {},
3203
3455
  })
3204
3456
  // The resumed turn can itself hit another durably-pending call/
@@ -3211,10 +3463,12 @@ async function respondAfterResolutionNow(res: ServerResponse, finalCheckpoint: T
3211
3463
  return { newMessages: result.newMessages, result: { text: result.text, stopReason: result.stopReason, pending: pendingSummary(result.pending) } }
3212
3464
  })
3213
3465
 
3214
- // A scheduled task's paused run (core/scheduler.ts) settles here.
3215
- void scheduler
3216
- .settleFromResume(finalCheckpoint.sessionId, { text: responseBody.text, pendingIds: responseBody.pending?.map((p) => p.pendingId) })
3217
- .catch((err) => console.error('[loopengine] could not settle scheduled run:', err))
3466
+ // A scheduled task's paused run (core/scheduler.ts), or a workflow run's
3467
+ // (core/workflow-runs.ts), settles here.
3468
+ const resumed = { text: responseBody.text, pendingIds: responseBody.pending?.map((p) => p.pendingId) }
3469
+ void scheduler.settleFromResume(finalCheckpoint.sessionId, resumed).catch((err) => console.error('[loopengine] could not settle scheduled run:', err))
3470
+ void workflowRunner.settleFromResume(finalCheckpoint.sessionId, resumed).catch((err) => console.error('[loopengine] could not settle workflow run:', err))
3471
+ if (fromJobThread) void settleJobFromResume(finalCheckpoint.agent, finalCheckpoint.sessionId, responseBody.text, responseBody.pending).catch((err) => console.error('[loopengine] could not answer in the job thread:', err))
3218
3472
  res.writeHead(200, { 'content-type': 'application/json' }).end(
3219
3473
  JSON.stringify({ text: responseBody.text, sessionId: finalCheckpoint.sessionId, ...(responseBody.stopReason ? { stopReason: responseBody.stopReason } : {}), ...(responseBody.pending ? { pending: responseBody.pending } : {}) }),
3220
3474
  )
@@ -3407,7 +3661,7 @@ async function tracked<T>(fn: () => Promise<T>): Promise<T> {
3407
3661
  async function handleActivityGet(res: ServerResponse): Promise<void> {
3408
3662
  res.writeHead(200, { 'content-type': 'application/json' }).end(
3409
3663
  JSON.stringify({
3410
- inFlightTurns: sessionTurns.size + scheduler.running + backgroundWork,
3664
+ inFlightTurns: sessionTurns.size + scheduler.running + workflowRunner.running + backgroundWork,
3411
3665
  scheduledRuns: scheduler.running,
3412
3666
  // When the next scheduled task is due — wake this server by then
3413
3667
  // (POST /internal/scheduler/tick) if it's been stopped while idle.
@@ -3589,24 +3843,217 @@ async function handleSlackEvents(req: IncomingMessage, res: ServerResponse, agen
3589
3843
  }
3590
3844
  // Slack wants an answer within three seconds; the reply is posted after.
3591
3845
  res.writeHead(200).end()
3592
- if (event.type !== 'message' || !firstSighting(event) || !slackConversationsOn(entry.config)) return
3593
- await handleInboundMessage(event, slackMessagingDeps(agentName, entry, botToken))
3846
+ if (event.type !== 'message') return
3847
+ await routeInboundMessage(agentName, entry, event, createSlackConnector(botToken))
3848
+ }
3849
+
3850
+ // Where a person's message goes (core/messaging/): a reply under a
3851
+ // request still waiting goes to the request's thread conversation; a DM,
3852
+ // a mention, or a message in a job's thread runs the agent
3853
+ // (core/messaging/jobs.ts). Anything else is left alone. Checked before
3854
+ // the message counts as seen: Slack sends a mention twice (as a message
3855
+ // and as an app_mention), and only the app_mention starts a job.
3856
+ async function routeInboundMessage(agentName: string, entry: RegistryEntry, message: InboundMessage, connector: MessagingConnector): Promise<void> {
3857
+ const app = connector.app as TwoWayApp
3858
+ const dir = agentDirFor(entry.config)
3859
+ const jobs = jobsOn(entry.config, app) && isJobMessage(dir, message)
3860
+ let target: 'request' | 'job' | undefined = jobs ? 'job' : undefined
3861
+ if (message.threadTs) {
3862
+ const link = findThread(dir, app, message.conversation, message.threadTs)
3863
+ // A request already decided in a job's thread: the job carries on.
3864
+ if (link && (!jobs || (await checkpoints.get(link.pendingId)))) target = conversationsOn(entry.config, app) ? 'request' : undefined
3865
+ }
3866
+ if (!target || !firstSighting(message)) return
3867
+ if (target === 'request') await handleInboundMessage(message, messagingDeps(agentName, entry, connector))
3868
+ else await handleJobMessage(message, jobDeps(agentName, entry, connector))
3869
+ }
3870
+
3871
+ // A Telegram bot's webhook (core/messaging/connectors/telegram/), set up
3872
+ // from the Messaging tab: people's messages (replies to a request, and
3873
+ // DMs and mentions that start jobs) and clicks on the agent's buttons.
3874
+ // Outside the admin login like Slack's endpoints, and checked against the
3875
+ // secret Telegram was given when the webhook was set. Telegram resends an
3876
+ // update that isn't answered 200 promptly, so it's answered first.
3877
+ async function handleTelegramWebhook(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
3878
+ const rawBody = await readRawBody(req)
3879
+ const entry = getEntry(agentName)
3880
+ const { botToken, webhookSecret } = entry ? telegramCredentialsFor(entry.config) : {}
3881
+ if (!entry || !botToken || !webhookSecret) {
3882
+ res.writeHead(404, { 'content-type': 'text/plain' }).end('No Telegram webhook is set up for this agent.')
3883
+ return
3884
+ }
3885
+ const header = req.headers['x-telegram-bot-api-secret-token']
3886
+ if (!verifyTelegramRequest(typeof header === 'string' ? header : undefined, webhookSecret)) {
3887
+ res.writeHead(401, { 'content-type': 'text/plain' }).end('Invalid Telegram secret token.')
3888
+ return
3889
+ }
3890
+ res.writeHead(200).end()
3891
+ const dir = agentDirFor(entry.config)
3892
+ const update = parseTelegramUpdate(rawBody, { botUsername: await telegramBotUsername(botToken), rootOf: (chat, id) => telegramRootOf(dir, chat, id) })
3893
+ const connector = createTelegramConnector(botToken, dir)
3894
+ if (update.type === 'message') {
3895
+ // Kept for the thread's context and the approver picker: Telegram
3896
+ // can't be asked for either later.
3897
+ recordTelegramMessage(dir, {
3898
+ chat: update.conversation, id: update.ts, root: update.threadTs ?? update.ts, user: update.user, fromBot: false, text: update.text,
3899
+ ...(update.userName ? { name: update.userName } : {}), ...(update.username ? { username: update.username } : {}),
3900
+ })
3901
+ await routeInboundMessage(agentName, entry, update, connector)
3902
+ } else if (update.type === 'click') {
3903
+ await handleTelegramClick(agentName, entry, botToken, connector, update)
3904
+ }
3905
+ }
3906
+
3907
+ // A click on one of the agent's Telegram buttons: a request's Approve or
3908
+ // Deny, a question's option, or a button under a thread reply (Approve as
3909
+ // changed, Keep original, Confirm, Cancel). The message the button was on
3910
+ // is edited to say what happened, which also takes its buttons away.
3911
+ async function handleTelegramClick(agentName: string, entry: RegistryEntry, botToken: string, connector: MessagingConnector, click: Extract<TelegramUpdate, { type: 'click' }>): Promise<void> {
3912
+ // Once the agent has Telegram approvers, only their clicks count.
3913
+ const gate = clickAllowed(entry.config, 'telegram', click.user)
3914
+ if (!gate.allowed) {
3915
+ await answerTelegramClick(botToken, click.callbackId, gate.message)
3916
+ return
3917
+ }
3918
+ await answerTelegramClick(botToken, click.callbackId)
3919
+ const deps = messagingDeps(agentName, entry, connector)
3920
+ const who = click.username ? `@${click.username}` : click.userName ?? 'someone'
3921
+ if (click.button.type === 'action') {
3922
+ const text = await handleMessagingAction(click.button.action, click.pendingId, click.user, deps)
3923
+ await editTelegramMessage(botToken, click.conversation, click.messageId, text)
3924
+ return
3925
+ }
3926
+ const item = (await checkpoints.get(click.pendingId))?.outstanding[click.pendingId]
3927
+ let decision: MessagingDecision
3928
+ let answer: string | undefined
3929
+ if (click.button.type === 'decide') {
3930
+ decision = { kind: click.button.decision }
3931
+ } else {
3932
+ const options = item?.args.options
3933
+ answer = Array.isArray(options) && typeof options[click.button.index] === 'string' ? (options[click.button.index] as string) : undefined
3934
+ // Gone from the checkpoint: decided already, or an option list that changed.
3935
+ decision = answer === undefined ? { kind: 'deny' } : { kind: 'answer', answer }
3936
+ if (answer === undefined) {
3937
+ await editTelegramMessage(botToken, click.conversation, click.messageId, item ? 'That option is no longer offered.' : 'This was already decided elsewhere.')
3938
+ return
3939
+ }
3940
+ }
3941
+ const outcome = await decideFromMessaging(click.pendingId, decision)
3942
+ const kind = decision.kind === 'answer' ? 'answer' : decision.kind
3943
+ if (!outcome.error && !outcome.alreadyResolved) {
3944
+ const done = kind === 'approve' ? 'Approved' : kind === 'deny' ? 'Denied' : 'Answered'
3945
+ await settleThreadChoice(deps, click.pendingId, `${done} by ${who} with the request's own buttons.`).catch((err) => console.error('[loopengine] Telegram update failed:', err))
3946
+ }
3947
+ // The request as it was, with what happened under it.
3948
+ const settled = !outcome.error && !outcome.alreadyResolved
3949
+ let result = outcomeText(kind, who, outcome, answer === undefined ? undefined : telegramEscape(answer))
3950
+ if (kind === 'approve' && settled) result = `**Approved by ${who}.** ${result}`
3951
+ const request = readTelegramThread(agentDirFor(entry.config), click.conversation, click.messageId).find((m) => m.ts === click.messageId)?.text
3952
+ if (request) result = `${request}\n\n${result}`
3953
+ else if (item && kind === 'approve' && settled) result += `\nRan \`${item.tool}\` with:\n${formatArgs(item.args, telegramEscape, (await resolveAgentTool(entry.config, item.tool))?.input_schema)}`
3954
+ await editTelegramMessage(botToken, click.conversation, click.messageId, result)
3955
+ }
3956
+
3957
+ // A job someone started by messaging the agent (core/messaging/jobs.ts).
3958
+ function jobDeps(agentName: string, entry: RegistryEntry, connector: MessagingConnector): JobDeps {
3959
+ const app = connector.app as TwoWayApp
3960
+ return {
3961
+ agentName,
3962
+ agentDir: agentDirFor(entry.config),
3963
+ connector,
3964
+ plan: process.env.LOOPENGINE_PLAN,
3965
+ allowed: (message) => jobAllowed(entry.config, app, message),
3966
+ runTurn: (sessionId, text) => runJobTurn(agentName, sessionId, text),
3967
+ decideLink: (pendingId, kind) => {
3968
+ const base = publicBaseUrl(connectionPublicUrl(entry.config, app))
3969
+ return base ? `${base}${decidePath(pendingId, kind)}` : undefined
3970
+ },
3971
+ }
3972
+ }
3973
+
3974
+ // A job thread's approvals and questions wait in the thread itself
3975
+ // (core/messaging/jobs.ts posts them), so the run's own approver only
3976
+ // hands out an id. The agent's notifier, when it has one, still sends its
3977
+ // request too, and wins: run-agent prefers it on the http channel.
3978
+ const threadApprover = {
3979
+ requestApproval: async () => false,
3980
+ requestDurableApproval: () => ({ pendingId: randomUUID() }),
3981
+ }
3982
+ const threadQuestions = {
3983
+ requestQuestion: async () => NO_HUMAN_ANSWER,
3984
+ notifyPendingQuestion: () => ({ pendingId: randomUUID() }),
3985
+ }
3986
+
3987
+ function jobTurnOf(text: string, pending: PendingSummary[] | undefined): JobTurn {
3988
+ if (!pending?.length) return { kind: 'done', text }
3989
+ return {
3990
+ kind: 'pending',
3991
+ items: pending.map((p) => ({
3992
+ pendingId: p.pendingId,
3993
+ kind: p.kind,
3994
+ tool: p.tool,
3995
+ ...(p.kind === 'question' ? { question: typeof p.args.question === 'string' ? p.args.question : p.reason } : {}),
3996
+ })),
3997
+ }
3998
+ }
3999
+
4000
+ // One turn of a job thread: the same session for the whole thread.
4001
+ async function runJobTurn(agent: string, rawSessionId: string, message: string): Promise<JobTurn> {
4002
+ const entry = getEntry(agent)
4003
+ if (!entry) throw new Error(`no agent named "${agent}" in this project`)
4004
+ const tenant = 'default'
4005
+ const environment = process.env.LOOPENGINE_ENV ?? 'production'
4006
+ const outcome = await sessions.withSession(`${tenant}:${environment}:${agent}:${rawSessionId}`, async (history) => {
4007
+ const result = await runAgent(entry.config, entry.createModelCall(), message, history, {
4008
+ tenant,
4009
+ sessionId: rawSessionId,
4010
+ channel: 'http',
4011
+ approver: threadApprover,
4012
+ questionHandler: threadQuestions,
4013
+ })
4014
+ if (result.pending) await createCheckpointFromPending(agent, tenant, rawSessionId, result.pending)
4015
+ return { newMessages: result.newMessages, result: { text: result.text, pending: pendingSummary(result.pending) } }
4016
+ })
4017
+ return jobTurnOf(outcome.text, outcome.pending)
4018
+ }
4019
+
4020
+ // A resumed run that came from a job thread answers there.
4021
+ async function settleJobFromResume(agent: string, sessionId: string, text: string, pending: PendingSummary[] | undefined): Promise<void> {
4022
+ const entry = getEntry(agent)
4023
+ if (!entry) return
4024
+ const job = findJobThreadBySession(agentDirFor(entry.config), sessionId)
4025
+ if (!job) return
4026
+ let connector: MessagingConnector | undefined
4027
+ if (job.app === 'slack') {
4028
+ const { botToken } = slackCredentialsFor(entry.config)
4029
+ if (botToken) connector = createSlackConnector(botToken)
4030
+ } else if (job.app === 'telegram') {
4031
+ const { botToken } = telegramCredentialsFor(entry.config)
4032
+ if (botToken) connector = createTelegramConnector(botToken, agentDirFor(entry.config))
4033
+ }
4034
+ if (!connector) return
4035
+ await settleJobThread(jobDeps(agent, entry, connector), sessionId, jobTurnOf(text, pending))
3594
4036
  }
3595
4037
 
3596
4038
  // What core/messaging/ needs from this server to answer, decide and
3597
4039
  // change an agent's Slack requests.
3598
4040
  function slackMessagingDeps(agentName: string, entry: RegistryEntry, botToken: string): MessagingDeps {
4041
+ return messagingDeps(agentName, entry, createSlackConnector(botToken))
4042
+ }
4043
+
4044
+ // The same, for any app's connector.
4045
+ function messagingDeps(agentName: string, entry: RegistryEntry, connector: MessagingConnector): MessagingDeps {
3599
4046
  const environment = process.env.LOOPENGINE_ENV ?? 'production'
3600
4047
  return {
3601
4048
  agentName,
3602
4049
  agentDir: agentDirFor(entry.config),
3603
4050
  systemPrompt: entry.config.systemPrompt,
3604
- connector: createSlackConnector(botToken),
4051
+ connector,
3605
4052
  createModelCall: () => entry.createModelCall(),
3606
4053
  getCheckpoint: (pendingId) => checkpoints.get(pendingId),
3607
4054
  getHistory: (checkpoint) => sessions.getHistory(`${checkpoint.tenant}:${environment}:${checkpoint.agent}:${checkpoint.sessionId}`),
3608
4055
  plan: process.env.LOOPENGINE_PLAN,
3609
- approvers: () => agentApprovers(entry.config),
4056
+ approvers: () => agentApprovers(entry.config, connector.app as TwoWayApp),
3610
4057
  decide: decideFromMessaging,
3611
4058
  toolSchema: async (toolName) => (await resolveAgentTool(entry.config, toolName))?.input_schema,
3612
4059
  // The same checks the console's approve-with-edits applies, plus the
@@ -3630,6 +4077,10 @@ function slackMessagingDeps(agentName: string, entry: RegistryEntry, botToken: s
3630
4077
  // recorded through the same resolve and answer handlers as the console's
3631
4078
  // buttons.
3632
4079
  async function decideFromMessaging(pendingId: string, decision: MessagingDecision): Promise<DecisionOutcome> {
4080
+ // A run from a job thread posts its own result there once it resumes
4081
+ // (settleJobFromResume), so it isn't quoted here as well.
4082
+ const checkpoint = await checkpoints.get(pendingId)
4083
+ const fromJob = checkpoint ? Boolean(getEntry(checkpoint.agent) && findJobThreadBySession(agentDir(checkpoint.agent), checkpoint.sessionId)) : false
3633
4084
  const capture = captureResponse()
3634
4085
  if (decision.kind === 'answer') await answerPendingQuestion(capture.res, pendingId, { answer: decision.answer })
3635
4086
  else await resolvePendingApproval(capture.res, pendingId, { decision: decision.kind, ...(decision.kind === 'approve' && decision.editedArgs ? { editedArgs: decision.editedArgs } : {}) })
@@ -3638,7 +4089,106 @@ async function decideFromMessaging(pendingId: string, decision: MessagingDecisio
3638
4089
  if (body.alreadyResolved) return { alreadyResolved: true }
3639
4090
  if (body.resolved) return { stillWaiting: true }
3640
4091
  if (Array.isArray(body.pending) && body.pending.length) return { morePending: true }
3641
- return { text: typeof body.text === 'string' ? body.text : undefined }
4092
+ return { text: typeof body.text === 'string' && !fromJob ? body.text : undefined }
4093
+ }
4094
+
4095
+ // ---- Webhook triggers (core/workflow-triggers.ts): an outside service
4096
+ // posts its own events to a workflow's secret URL. Answered 202 as soon as
4097
+ // the run starts (senders retry a slow or failed answer); a retry of a
4098
+ // delivery already handled, or an event the filter skips, is answered 202
4099
+ // too, so it isn't sent again. ----
4100
+
4101
+ const WEBHOOK_MAX_BYTES = 1024 * 1024
4102
+
4103
+ async function handleWorkflowHook(req: IncomingMessage, res: ServerResponse, token: string): Promise<void> {
4104
+ const json = (status: number, body: unknown): void => {
4105
+ res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(JSON.stringify(body))
4106
+ }
4107
+ const raw = await readLimitedRawBuffer(req, WEBHOOK_MAX_BYTES)
4108
+ if (!raw) return json(413, { error: 'The payload is larger than 1 MB.' })
4109
+ // Unknown and turned-off triggers read the same, so a URL can't be probed.
4110
+ const trigger = await findTriggerByToken(token)
4111
+ if (!trigger || !trigger.enabled) return json(404, { error: 'not found' })
4112
+ const at = new Date().toISOString()
4113
+ const record = (outcome: Parameters<typeof recordDelivery>[1]['outcome'], extra: { delivery_id?: string; run_id?: string; detail?: string } = {}, seen?: string) =>
4114
+ recordDelivery(trigger.trigger_id, { at, outcome, ...extra }, seen)
4115
+
4116
+ if (trigger.verify && !verifySenderSignature(trigger.verify, req.headers, raw)) {
4117
+ await record('rejected', { detail: `The ${trigger.verify.sender} signature didn't match.` })
4118
+ return json(401, { error: 'signature check failed' })
4119
+ }
4120
+ const payload = parsePayload(raw, req.headers['content-type'])
4121
+ const id = deliveryId(req.headers, payload)
4122
+ if (id && trigger.seen_ids.includes(id)) {
4123
+ await record('duplicate', { delivery_id: id })
4124
+ return json(202, { ignored: 'duplicate' })
4125
+ }
4126
+ if (!passesFilter(trigger.filter, payload)) {
4127
+ await record('filtered', { ...(id ? { delivery_id: id } : {}) }, id)
4128
+ return json(202, { ignored: 'filtered' })
4129
+ }
4130
+ if (!getEntry(trigger.agent)) {
4131
+ await record('error', { detail: `The agent ${trigger.agent} is gone.` })
4132
+ return json(404, { error: 'not found' })
4133
+ }
4134
+ try {
4135
+ const prepared = await prepareWorkflowRun(trigger.agent, agentDir(trigger.agent), trigger.workflow_id, paramsFromPayload(trigger, payload))
4136
+ const run = await workflowRunner.start({
4137
+ agent: trigger.agent, workflow_id: trigger.workflow_id, params: prepared.params, source: 'webhook', trigger_id: trigger.trigger_id, message: prepared.message,
4138
+ ...(trigger.notify ? { notify: true } : {}), ...(trigger.callback_url ? { callback_url: trigger.callback_url } : {}),
4139
+ })
4140
+ await record('started', { run_id: run.run_id, ...(id ? { delivery_id: id } : {}) }, id)
4141
+ return json(202, { runId: run.run_id })
4142
+ } catch (err) {
4143
+ const message = err instanceof Error ? err.message : String(err)
4144
+ if (err instanceof WorkflowError && /already running/.test(message)) {
4145
+ await record('busy', { detail: message, ...(id ? { delivery_id: id } : {}) }, id)
4146
+ return json(202, { ignored: 'already running' })
4147
+ }
4148
+ await record(err instanceof WorkflowError ? 'invalid' : 'error', { detail: message, ...(id ? { delivery_id: id } : {}) })
4149
+ return json(err instanceof WorkflowError ? 422 : 500, { error: message })
4150
+ }
4151
+ }
4152
+
4153
+ // This server's address as the request reached it (behind a proxy, as
4154
+ // the proxy says), for URLs shown in the console.
4155
+ function requestBaseUrl(req: IncomingMessage): string {
4156
+ const forwardedProto = String(req.headers['x-forwarded-proto'] ?? '').split(',')[0]
4157
+ return `${forwardedProto || ((req.socket as { encrypted?: boolean }).encrypted ? 'https' : 'http')}://${String(req.headers['x-forwarded-host'] ?? req.headers.host ?? 'localhost')}`
4158
+ }
4159
+
4160
+ // A workflow's webhook triggers, for the console: list, make (its URL is
4161
+ // shown once), change, rotate (a new URL), delete.
4162
+ async function handleWorkflowTriggers(req: IncomingMessage, res: ServerResponse, agentName: string, workflowId: string, triggerId: string | undefined, action: string | undefined): Promise<void> {
4163
+ const json = (status: number, body: unknown): void => {
4164
+ res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(JSON.stringify(body))
4165
+ }
4166
+ if (!getEntry(agentName)) return json(404, { error: `unknown agent '${agentName}'` })
4167
+ const workflow = readWorkflow(agentDir(agentName), workflowId)
4168
+ if (!workflow) return json(404, { error: `no workflow ${workflowId} in ${agentName}` })
4169
+ const urlFor = (token: string) => `${requestBaseUrl(req)}/hooks/workflows/${token}`
4170
+ try {
4171
+ if (!triggerId && req.method === 'GET') return json(200, { triggers: (await listTriggers(agentName, workflowId)).map(publicTrigger) })
4172
+ if (!triggerId && req.method === 'POST') {
4173
+ const { trigger, token } = await createTrigger(agentName, workflow, await readJsonBody(req))
4174
+ return json(201, { trigger: publicTrigger(trigger), url: urlFor(token) })
4175
+ }
4176
+ const existing = triggerId ? await getTriggerStore().get(triggerId) : undefined
4177
+ if (!existing || existing.agent !== agentName || existing.workflow_id !== workflowId) return json(404, { error: 'no such trigger' })
4178
+ if (action === 'rotate' && req.method === 'POST') {
4179
+ const { trigger, token } = await rotateTrigger(existing.trigger_id)
4180
+ return json(200, { trigger: publicTrigger(trigger), url: urlFor(token) })
4181
+ }
4182
+ if (!action && req.method === 'PUT') return json(200, { trigger: publicTrigger(await updateTrigger(existing.trigger_id, workflow, await readJsonBody(req))) })
4183
+ if (!action && req.method === 'DELETE') {
4184
+ await getTriggerStore().remove(existing.trigger_id)
4185
+ return json(200, { removed: true })
4186
+ }
4187
+ return json(404, { error: 'not found' })
4188
+ } catch (err) {
4189
+ const message = err instanceof Error ? err.message : String(err)
4190
+ return json(err instanceof TriggerError || err instanceof CallbackUrlError ? 400 : 500, { error: message })
4191
+ }
3642
4192
  }
3643
4193
 
3644
4194
  // Set once shutdown() starts, so /healthz turns 503 while the server
@@ -3679,6 +4229,31 @@ const server = createServer(async (req, res) => {
3679
4229
  return
3680
4230
  }
3681
4231
 
4232
+ const telegramHookMatch = req.method === 'POST' && (req.url ?? '/').split('?')[0].match(/^\/hooks\/telegram\/([^/]+)$/)
4233
+ if (telegramHookMatch) {
4234
+ try {
4235
+ lastRequestAt = Date.now()
4236
+ await tracked(() => handleTelegramWebhook(req, res, decodeURIComponent(telegramHookMatch[1])))
4237
+ } catch (err) {
4238
+ console.error('[loopengine] Telegram update failed:', err)
4239
+ if (!res.headersSent) res.writeHead(500).end()
4240
+ }
4241
+ return
4242
+ }
4243
+
4244
+ // A workflow's webhook trigger: its token is the credential (no login).
4245
+ const workflowHookMatch = req.method === 'POST' && (req.url ?? '/').split('?')[0].match(/^\/hooks\/workflows\/([A-Za-z0-9_-]+)$/)
4246
+ if (workflowHookMatch) {
4247
+ try {
4248
+ lastRequestAt = Date.now()
4249
+ await tracked(() => handleWorkflowHook(req, res, workflowHookMatch[1]))
4250
+ } catch (err) {
4251
+ console.error('[loopengine] workflow webhook failed:', err)
4252
+ if (!res.headersSent) res.writeHead(500, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'Something went wrong.' }))
4253
+ }
4254
+ return
4255
+ }
4256
+
3682
4257
  const decideMatch = (req.method === 'GET' || req.method === 'POST') && (req.url ?? '/').split('?')[0].match(/^\/decide\/([A-Za-z0-9_.-]+)$/)
3683
4258
  if (decideMatch) {
3684
4259
  try {
@@ -3949,7 +4524,26 @@ const server = createServer(async (req, res) => {
3949
4524
  await handleAgentApp(req, res, decodeURIComponent(agentAppMatch[1]), decodeURIComponent(agentAppMatch[2]))
3950
4525
  return
3951
4526
  }
3952
- const agentWorkflowsMatch = pathname.match(/^\/agents\/([^/]+)\/workflows(?:\/([a-z0-9-]+))?(?:\/(run))?$/)
4527
+ // The secret run callbacks are signed with, for whoever checks them.
4528
+ if (req.method === 'GET' && pathname === '/runs/callback-secret') {
4529
+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(JSON.stringify({ secret: callbackSecret() }))
4530
+ return
4531
+ }
4532
+ // One workflow run, by the runId its POST .../run returned.
4533
+ const agentRunMatch = req.method === 'GET' && pathname.match(/^\/agents\/([^/]+)\/runs\/([A-Za-z0-9_-]+)$/)
4534
+ if (agentRunMatch) {
4535
+ const agentName = decodeURIComponent(agentRunMatch[1])
4536
+ const run = getEntry(agentName) ? await getRun(agentName, agentRunMatch[2]) : undefined
4537
+ res.writeHead(run ? 200 : 404, { 'content-type': 'application/json', 'cache-control': 'no-store' })
4538
+ .end(JSON.stringify(run ?? { error: `no run ${agentRunMatch[2]} for ${agentName}` }))
4539
+ return
4540
+ }
4541
+ const triggersMatch = pathname.match(/^\/agents\/([^/]+)\/workflows\/([a-z0-9-]+)\/triggers(?:\/([A-Za-z0-9_-]+))?(?:\/(rotate))?$/)
4542
+ if (triggersMatch) {
4543
+ await handleWorkflowTriggers(req, res, decodeURIComponent(triggersMatch[1]), triggersMatch[2], triggersMatch[3], triggersMatch[4])
4544
+ return
4545
+ }
4546
+ const agentWorkflowsMatch = pathname.match(/^\/agents\/([^/]+)\/workflows(?:\/([a-z0-9-]+))?(?:\/(run|runs))?$/)
3953
4547
  if (agentWorkflowsMatch) {
3954
4548
  await handleAgentWorkflows(req, res, decodeURIComponent(agentWorkflowsMatch[1]), agentWorkflowsMatch[2], agentWorkflowsMatch[3])
3955
4549
  return
@@ -4020,7 +4614,7 @@ const server = createServer(async (req, res) => {
4020
4614
  return
4021
4615
  }
4022
4616
  try {
4023
- const outcome = await runUpgrade({ busy: () => sessionTurns.size + scheduler.running > 0 })
4617
+ const outcome = await runUpgrade({ busy: () => sessionTurns.size + scheduler.running + workflowRunner.running > 0 })
4024
4618
  res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true, outcome, restarting: exitOnCodeChange }))
4025
4619
  } catch (err) {
4026
4620
  const status = err instanceof UpgradeError ? 409 : 500
@@ -4264,6 +4858,19 @@ const server = createServer(async (req, res) => {
4264
4858
  await handleSchedulesPost(req, res, decodeURIComponent(schedulesMatch[1]))
4265
4859
  return
4266
4860
  }
4861
+ // POST /schedules/:id/notify { "notify": true|false }: whether each
4862
+ // run's result goes through the agent's notifier.
4863
+ const scheduleNotifyMatch = req.method === 'POST' && pathname.match(/^\/schedules\/([A-Za-z0-9_-]+)\/notify$/)
4864
+ if (scheduleNotifyMatch) {
4865
+ const body = await readJsonBody(req)
4866
+ if (typeof body.notify !== 'boolean') return scheduleJson(res, 400, { error: 'notify must be true or false' })
4867
+ try {
4868
+ scheduleJson(res, 200, await setScheduledTaskNotify(scheduleNotifyMatch[1], body.notify))
4869
+ } catch (err) {
4870
+ scheduleJson(res, err instanceof ScheduleError ? 404 : 500, { error: err instanceof Error ? err.message : String(err) })
4871
+ }
4872
+ return
4873
+ }
4267
4874
  const scheduleActionMatch = req.method === 'POST' && pathname.match(/^\/schedules\/([A-Za-z0-9_-]+)\/(run|pause|resume|cancel)$/)
4268
4875
  if (scheduleActionMatch) {
4269
4876
  await handleScheduleAction(res, scheduleActionMatch[1], scheduleActionMatch[2])
@@ -4281,6 +4888,18 @@ const server = createServer(async (req, res) => {
4281
4888
  await handleApproversPut(req, res, decodeURIComponent(approversMatch[1]))
4282
4889
  return
4283
4890
  }
4891
+ const telegramPeopleMatch = req.method === 'GET' && pathname.match(/^\/agents\/([^/]+)\/notifier\/telegram-people$/)
4892
+ if (telegramPeopleMatch) {
4893
+ const entry = getEntry(decodeURIComponent(telegramPeopleMatch[1]))
4894
+ if (!entry) res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'unknown agent' }))
4895
+ else res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ people: listAgentTelegramPeople(entry.config) }))
4896
+ return
4897
+ }
4898
+ const telegramWebhookMatch = (req.method === 'GET' || req.method === 'POST') && pathname.match(/^\/agents\/([^/]+)\/notifier\/telegram-webhook$/)
4899
+ if (telegramWebhookMatch) {
4900
+ await handleTelegramWebhookSetup(req, res, decodeURIComponent(telegramWebhookMatch[1]))
4901
+ return
4902
+ }
4284
4903
  const slackMembersMatch = req.method === 'GET' && pathname.match(/^\/agents\/([^/]+)\/notifier\/slack-members$/)
4285
4904
  if (slackMembersMatch) {
4286
4905
  await handleSlackMembers(res, decodeURIComponent(slackMembersMatch[1]))
@@ -4540,7 +5159,16 @@ server.listen(port, () => {
4540
5159
  console.log(`agent API listening on :${port}`)
4541
5160
  void warmAgentTools()
4542
5161
  // LOOPENGINE_SCHEDULER=off leaves scheduling to another process.
4543
- if (process.env.LOOPENGINE_SCHEDULER !== 'off') void scheduler.start().catch((err) => console.error('[loopengine] scheduler failed to start:', err))
5162
+ // Workflows from before they were skills move into their skills first,
5163
+ // so their schedules run them.
5164
+ const startScheduling = async () => {
5165
+ for (const name of listAgents()) await migrateAgentWorkflows(name, agentDir(name)).catch((err) => console.error(`[loopengine] moving ${name}'s workflows.yml failed:`, err))
5166
+ if (process.env.LOOPENGINE_SCHEDULER !== 'off') {
5167
+ await workflowRunner.recover()
5168
+ await scheduler.start()
5169
+ }
5170
+ }
5171
+ void startScheduling().catch((err) => console.error('[loopengine] scheduler failed to start:', err))
4544
5172
  })
4545
5173
 
4546
5174
  // An agent that leaves `tools` unset gets its tools/ folder imported
@@ -9,7 +9,7 @@
9
9
  "cli": "tsx adapters/cli.ts"
10
10
  },
11
11
  "dependencies": {
12
- "loopengine": "^0.2.2",
12
+ "loopengine": "^0.3.3",
13
13
  "actauth": "^0.0.12",
14
14
  "tsx": "^4.19.0",
15
15
  "typescript": "^5.7.0"