create-loopengine 0.3.0 → 0.3.2

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.3.0",
3
+ "version": "0.3.2",
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",
@@ -67,6 +67,7 @@ import {
67
67
  type AgentConfig,
68
68
  type AgentModelConfig,
69
69
  type ToolDefinition,
70
+ type Message,
70
71
  type ModelContentBlock,
71
72
  type RunAgentResult,
72
73
  playgroundHtml,
@@ -120,8 +121,11 @@ import {
120
121
  agentDirFor,
121
122
  verifyProxyIdentity,
122
123
  TRUSTED_PROXY_IDENTITY_HEADER,
124
+ reportRun,
125
+ usedTool,
123
126
  NotifierFileError,
124
127
  verifyDecisionToken,
128
+ decidePath,
125
129
  renderDecidePage,
126
130
  renderDecideGonePage,
127
131
  renderDecideInvalidPage,
@@ -145,7 +149,16 @@ import {
145
149
  listAgentPending,
146
150
  listAgentSlackChannels,
147
151
  slackCredentialsFor,
148
- slackConversationsOn,
152
+ agentTelegramWebhookInfo,
153
+ clickAllowed,
154
+ connectionPublicUrl,
155
+ conversationsOn,
156
+ jobAllowed,
157
+ jobsOn,
158
+ listAgentTelegramPeople,
159
+ setupAgentTelegramWebhook,
160
+ telegramCredentialsFor,
161
+ type TwoWayApp,
149
162
  slackClickAllowed,
150
163
  agentApprovers,
151
164
  setAgentApprovers,
@@ -167,6 +180,28 @@ import {
167
180
  firstSighting,
168
181
  handleInboundMessage,
169
182
  createSlackConnector,
183
+ outcomeText,
184
+ handleJobMessage,
185
+ isJobMessage,
186
+ settleJobThread,
187
+ type JobDeps,
188
+ type JobTurn,
189
+ findJobThreadBySession,
190
+ findThread,
191
+ type InboundMessage,
192
+ type MessagingConnector,
193
+ formatArgs,
194
+ answerTelegramClick,
195
+ createTelegramConnector,
196
+ editTelegramMessage,
197
+ parseTelegramUpdate,
198
+ readTelegramThread,
199
+ recordTelegramMessage,
200
+ telegramBotUsername,
201
+ telegramEscape,
202
+ telegramRootOf,
203
+ verifyTelegramRequest,
204
+ type TelegramUpdate,
170
205
  parseSlackEvent,
171
206
  removeAgentNotifier,
172
207
  saveAgentNotifier,
@@ -276,6 +311,7 @@ import {
276
311
  createScheduleTools,
277
312
  type PendingQuestion,
278
313
  WebhookNotifier,
314
+ publicBaseUrl,
279
315
  // Workflows (skills with a start message), their runs, run callbacks
280
316
  // and webhook triggers.
281
317
  WorkflowParamsError,
@@ -1478,6 +1514,11 @@ async function handleWorkflows(req: IncomingMessage, res: ServerResponse, pathna
1478
1514
  return entry ? describeAgentNotifier(entry.config, defaultWebhook).source !== 'none' : false
1479
1515
  },
1480
1516
  })
1517
+ // The planned webhook's URL, shown once on the done screen.
1518
+ if (created.webhook) {
1519
+ const { token, ...webhook } = created.webhook
1520
+ return json(200, { ...created, webhook: { ...webhook, url: `${requestBaseUrl(req)}/hooks/workflows/${token}` } })
1521
+ }
1481
1522
  json(200, created)
1482
1523
  // New tools in an agent that was already running load with a restart,
1483
1524
  // as after installing an ability from its tab.
@@ -1887,6 +1928,8 @@ async function executeUnattendedRun(agent: string, message: string, rawSessionId
1887
1928
  async function reportWorkflowRun(run: WorkflowRun): Promise<void> {
1888
1929
  const event: RunCallbackEvent | undefined = run.status === 'done' ? 'run.done' : run.status === 'error' ? 'run.failed' : run.status === 'awaiting_approval' ? 'run.awaiting_approval' : undefined
1889
1930
  if (!event) return
1931
+ // For a host's run observer (core/run-events.ts); a failed run isn't reported.
1932
+ if (run.status !== 'error') reportRun({ agent: run.agent, source: 'workflow', id: run.run_id, via: run.source, usedTool: true })
1890
1933
  const entry = getEntry(run.agent)
1891
1934
  if (run.notify && entry && event !== 'run.awaiting_approval') {
1892
1935
  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'}`
@@ -1904,7 +1947,12 @@ const workflowRunner = new WorkflowRunner({ execute: executeUnattendedRun, onCha
1904
1947
  // A task that runs a workflow is also recorded as a run of that workflow,
1905
1948
  // so the workflow's Recent runs shows its scheduled runs too.
1906
1949
  function fireScheduledTask(task: ScheduledTask, rawSessionId: string): ReturnType<typeof executeUnattendedRun> {
1907
- if (!task.workflow_id) return executeUnattendedRun(task.agent, task.message, rawSessionId)
1950
+ if (!task.workflow_id) {
1951
+ return executeUnattendedRun(task.agent, task.message, rawSessionId).then((outcome) => {
1952
+ reportRun({ agent: task.agent, source: 'schedule', id: randomUUID(), usedTool: true })
1953
+ return outcome
1954
+ })
1955
+ }
1908
1956
  return workflowRunner.runForSchedule({
1909
1957
  agent: task.agent,
1910
1958
  workflow_id: task.workflow_id,
@@ -2134,7 +2182,7 @@ async function handleApproversPut(req: IncomingMessage, res: ServerResponse, age
2134
2182
  }
2135
2183
  const body = await readJsonBody(req)
2136
2184
  try {
2137
- const approvers = setAgentApprovers(entry.config, body.approvers)
2185
+ const approvers = setAgentApprovers(entry.config, body.approvers, body.app === 'telegram' ? 'telegram' : 'slack')
2138
2186
  res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ approvers }))
2139
2187
  } catch (err) {
2140
2188
  const status = err instanceof NotifierFileError ? 400 : 500
@@ -2142,6 +2190,26 @@ async function handleApproversPut(req: IncomingMessage, res: ServerResponse, age
2142
2190
  }
2143
2191
  }
2144
2192
 
2193
+ // Messaging tab → Telegram: where the bot's updates go (GET), and
2194
+ // pointing them at this server (POST), which turns on buttons and
2195
+ // replies. Setting it needs the admin login, like saving a secret.
2196
+ async function handleTelegramWebhookSetup(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
2197
+ const json = (status: number, body: unknown): void => {
2198
+ res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(JSON.stringify(body))
2199
+ }
2200
+ const entry = getEntry(agentName)
2201
+ if (!entry) return json(404, { error: `unknown agent '${agentName}'` })
2202
+ try {
2203
+ if (req.method === 'GET') return json(200, await agentTelegramWebhookInfo(entry.config))
2204
+ if (!secretsProtected) return json(403, { error: 'Setting up the Telegram webhook needs LOOPENGINE_ADMIN_AUTH set on the server.' })
2205
+ const forwardedProto = String(req.headers['x-forwarded-proto'] ?? '').split(',')[0]
2206
+ const base = `${forwardedProto || ((req.socket as { encrypted?: boolean }).encrypted ? 'https' : 'http')}://${String(req.headers['x-forwarded-host'] ?? req.headers.host ?? 'localhost')}`
2207
+ return json(200, await setupAgentTelegramWebhook(entry.config, base))
2208
+ } catch (err) {
2209
+ return json(400, { error: err instanceof Error ? err.message : String(err) })
2210
+ }
2211
+ }
2212
+
2145
2213
  async function handleSlackMembers(res: ServerResponse, agentName: string): Promise<void> {
2146
2214
  const entry = getEntry(agentName)
2147
2215
  if (!entry) {
@@ -2859,6 +2927,19 @@ async function raceAndRespond(res: ServerResponse, agentName: string, rawSession
2859
2927
  res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(responseBody))
2860
2928
  }
2861
2929
 
2930
+ // Who sent a message, as a trusted proxy says in its signed identity:
2931
+ // an API key (role "api") or a signed-in person. Without a proxy, unknown.
2932
+ function messageCaller(req: IncomingMessage): 'api' | 'console' | 'unknown' {
2933
+ const identity = trustedProxySecret ? verifyProxyIdentity(req.headers[TRUSTED_PROXY_IDENTITY_HEADER], trustedProxySecret) : undefined
2934
+ if (!identity) return 'unknown'
2935
+ return identity.role === 'api' ? 'api' : 'console'
2936
+ }
2937
+
2938
+ // For a host's run observer (core/run-events.ts).
2939
+ function reportMessageRun(req: IncomingMessage, agent: string, newMessages: Message[]): void {
2940
+ reportRun({ agent, source: 'message', id: randomUUID(), caller: messageCaller(req), usedTool: usedTool(newMessages) })
2941
+ }
2942
+
2862
2943
  async function handleMessages(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
2863
2944
  const parsed = await parseRequest(req, agentName)
2864
2945
  if (!parsed.ok) {
@@ -2924,6 +3005,7 @@ async function handleMessages(req: IncomingMessage, res: ServerResponse, agentNa
2924
3005
  pushSignal(state, { type: 'question', entry: question })
2925
3006
  },
2926
3007
  })
3008
+ reportMessageRun(req, agentName, result.newMessages)
2927
3009
  if (result.pending) {
2928
3010
  await createCheckpointFromPending(agentName, tenant, rawSessionId, result.pending)
2929
3011
  }
@@ -3034,6 +3116,7 @@ async function handleMessagesStream(req: IncomingMessage, res: ServerResponse, a
3034
3116
  pushSignal(state, { type: 'question', entry: question })
3035
3117
  },
3036
3118
  })
3119
+ reportMessageRun(req, agentName, result.newMessages)
3037
3120
  if (result.pending) {
3038
3121
  await createCheckpointFromPending(agentName, tenant, rawSessionId, result.pending)
3039
3122
  }
@@ -3362,6 +3445,7 @@ async function respondAfterResolutionNow(res: ServerResponse, finalCheckpoint: T
3362
3445
  }
3363
3446
  const environment = process.env.LOOPENGINE_ENV ?? 'production'
3364
3447
  const storageSessionId = `${finalCheckpoint.tenant}:${environment}:${finalCheckpoint.agent}:${finalCheckpoint.sessionId}`
3448
+ const fromJobThread = Boolean(findJobThreadBySession(agentDirFor(entry.config), finalCheckpoint.sessionId))
3365
3449
 
3366
3450
  const responseBody = await sessions.withSession(storageSessionId, async (history) => {
3367
3451
  // Same channel/approver/questionHandler fallback the original
@@ -3389,8 +3473,9 @@ async function respondAfterResolutionNow(res: ServerResponse, finalCheckpoint: T
3389
3473
  tenant: finalCheckpoint.tenant,
3390
3474
  sessionId: finalCheckpoint.sessionId,
3391
3475
  channel: 'http',
3392
- approver: defaultHttpWebhookNotifier ?? createTrackedApprover(finalCheckpoint.sessionId),
3393
- questionHandler: defaultHttpWebhookNotifier,
3476
+ // A job thread's run keeps asking in its thread (runJobTurn).
3477
+ approver: fromJobThread ? threadApprover : defaultHttpWebhookNotifier ?? createTrackedApprover(finalCheckpoint.sessionId),
3478
+ questionHandler: fromJobThread ? threadQuestions : defaultHttpWebhookNotifier,
3394
3479
  onQuestionPending: () => {},
3395
3480
  })
3396
3481
  // The resumed turn can itself hit another durably-pending call/
@@ -3408,6 +3493,7 @@ async function respondAfterResolutionNow(res: ServerResponse, finalCheckpoint: T
3408
3493
  const resumed = { text: responseBody.text, pendingIds: responseBody.pending?.map((p) => p.pendingId) }
3409
3494
  void scheduler.settleFromResume(finalCheckpoint.sessionId, resumed).catch((err) => console.error('[loopengine] could not settle scheduled run:', err))
3410
3495
  void workflowRunner.settleFromResume(finalCheckpoint.sessionId, resumed).catch((err) => console.error('[loopengine] could not settle workflow run:', err))
3496
+ 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))
3411
3497
  res.writeHead(200, { 'content-type': 'application/json' }).end(
3412
3498
  JSON.stringify({ text: responseBody.text, sessionId: finalCheckpoint.sessionId, ...(responseBody.stopReason ? { stopReason: responseBody.stopReason } : {}), ...(responseBody.pending ? { pending: responseBody.pending } : {}) }),
3413
3499
  )
@@ -3782,24 +3868,219 @@ async function handleSlackEvents(req: IncomingMessage, res: ServerResponse, agen
3782
3868
  }
3783
3869
  // Slack wants an answer within three seconds; the reply is posted after.
3784
3870
  res.writeHead(200).end()
3785
- if (event.type !== 'message' || !firstSighting(event) || !slackConversationsOn(entry.config)) return
3786
- await handleInboundMessage(event, slackMessagingDeps(agentName, entry, botToken))
3871
+ if (event.type !== 'message') return
3872
+ await routeInboundMessage(agentName, entry, event, createSlackConnector(botToken))
3873
+ }
3874
+
3875
+ // Where a person's message goes (core/messaging/): a reply under a
3876
+ // request still waiting goes to the request's thread conversation; a DM,
3877
+ // a mention, or a message in a job's thread runs the agent
3878
+ // (core/messaging/jobs.ts). Anything else is left alone. Checked before
3879
+ // the message counts as seen: Slack sends a mention twice (as a message
3880
+ // and as an app_mention), and only the app_mention starts a job.
3881
+ async function routeInboundMessage(agentName: string, entry: RegistryEntry, message: InboundMessage, connector: MessagingConnector): Promise<void> {
3882
+ const app = connector.app as TwoWayApp
3883
+ const dir = agentDirFor(entry.config)
3884
+ const jobs = jobsOn(entry.config, app) && isJobMessage(dir, message)
3885
+ let target: 'request' | 'job' | undefined = jobs ? 'job' : undefined
3886
+ if (message.threadTs) {
3887
+ const link = findThread(dir, app, message.conversation, message.threadTs)
3888
+ // A request already decided in a job's thread: the job carries on.
3889
+ if (link && (!jobs || (await checkpoints.get(link.pendingId)))) target = conversationsOn(entry.config, app) ? 'request' : undefined
3890
+ }
3891
+ if (!target || !firstSighting(message)) return
3892
+ if (target === 'request') await handleInboundMessage(message, messagingDeps(agentName, entry, connector))
3893
+ else await handleJobMessage(message, jobDeps(agentName, entry, connector))
3894
+ }
3895
+
3896
+ // A Telegram bot's webhook (core/messaging/connectors/telegram/), set up
3897
+ // from the Messaging tab: people's messages (replies to a request, and
3898
+ // DMs and mentions that start jobs) and clicks on the agent's buttons.
3899
+ // Outside the admin login like Slack's endpoints, and checked against the
3900
+ // secret Telegram was given when the webhook was set. Telegram resends an
3901
+ // update that isn't answered 200 promptly, so it's answered first.
3902
+ async function handleTelegramWebhook(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
3903
+ const rawBody = await readRawBody(req)
3904
+ const entry = getEntry(agentName)
3905
+ const { botToken, webhookSecret } = entry ? telegramCredentialsFor(entry.config) : {}
3906
+ if (!entry || !botToken || !webhookSecret) {
3907
+ res.writeHead(404, { 'content-type': 'text/plain' }).end('No Telegram webhook is set up for this agent.')
3908
+ return
3909
+ }
3910
+ const header = req.headers['x-telegram-bot-api-secret-token']
3911
+ if (!verifyTelegramRequest(typeof header === 'string' ? header : undefined, webhookSecret)) {
3912
+ res.writeHead(401, { 'content-type': 'text/plain' }).end('Invalid Telegram secret token.')
3913
+ return
3914
+ }
3915
+ res.writeHead(200).end()
3916
+ const dir = agentDirFor(entry.config)
3917
+ const update = parseTelegramUpdate(rawBody, { botUsername: await telegramBotUsername(botToken), rootOf: (chat, id) => telegramRootOf(dir, chat, id) })
3918
+ const connector = createTelegramConnector(botToken, dir)
3919
+ if (update.type === 'message') {
3920
+ // Kept for the thread's context and the approver picker: Telegram
3921
+ // can't be asked for either later.
3922
+ recordTelegramMessage(dir, {
3923
+ chat: update.conversation, id: update.ts, root: update.threadTs ?? update.ts, user: update.user, fromBot: false, text: update.text,
3924
+ ...(update.userName ? { name: update.userName } : {}), ...(update.username ? { username: update.username } : {}),
3925
+ })
3926
+ await routeInboundMessage(agentName, entry, update, connector)
3927
+ } else if (update.type === 'click') {
3928
+ await handleTelegramClick(agentName, entry, botToken, connector, update)
3929
+ }
3930
+ }
3931
+
3932
+ // A click on one of the agent's Telegram buttons: a request's Approve or
3933
+ // Deny, a question's option, or a button under a thread reply (Approve as
3934
+ // changed, Keep original, Confirm, Cancel). The message the button was on
3935
+ // is edited to say what happened, which also takes its buttons away.
3936
+ async function handleTelegramClick(agentName: string, entry: RegistryEntry, botToken: string, connector: MessagingConnector, click: Extract<TelegramUpdate, { type: 'click' }>): Promise<void> {
3937
+ // Once the agent has Telegram approvers, only their clicks count.
3938
+ const gate = clickAllowed(entry.config, 'telegram', click.user)
3939
+ if (!gate.allowed) {
3940
+ await answerTelegramClick(botToken, click.callbackId, gate.message)
3941
+ return
3942
+ }
3943
+ await answerTelegramClick(botToken, click.callbackId)
3944
+ const deps = messagingDeps(agentName, entry, connector)
3945
+ const who = click.username ? `@${click.username}` : click.userName ?? 'someone'
3946
+ if (click.button.type === 'action') {
3947
+ const text = await handleMessagingAction(click.button.action, click.pendingId, click.user, deps)
3948
+ await editTelegramMessage(botToken, click.conversation, click.messageId, text)
3949
+ return
3950
+ }
3951
+ const item = (await checkpoints.get(click.pendingId))?.outstanding[click.pendingId]
3952
+ let decision: MessagingDecision
3953
+ let answer: string | undefined
3954
+ if (click.button.type === 'decide') {
3955
+ decision = { kind: click.button.decision }
3956
+ } else {
3957
+ const options = item?.args.options
3958
+ answer = Array.isArray(options) && typeof options[click.button.index] === 'string' ? (options[click.button.index] as string) : undefined
3959
+ // Gone from the checkpoint: decided already, or an option list that changed.
3960
+ decision = answer === undefined ? { kind: 'deny' } : { kind: 'answer', answer }
3961
+ if (answer === undefined) {
3962
+ await editTelegramMessage(botToken, click.conversation, click.messageId, item ? 'That option is no longer offered.' : 'This was already decided elsewhere.')
3963
+ return
3964
+ }
3965
+ }
3966
+ const outcome = await decideFromMessaging(click.pendingId, decision)
3967
+ const kind = decision.kind === 'answer' ? 'answer' : decision.kind
3968
+ if (!outcome.error && !outcome.alreadyResolved) {
3969
+ const done = kind === 'approve' ? 'Approved' : kind === 'deny' ? 'Denied' : 'Answered'
3970
+ await settleThreadChoice(deps, click.pendingId, `${done} by ${who} with the request's own buttons.`).catch((err) => console.error('[loopengine] Telegram update failed:', err))
3971
+ }
3972
+ // The request as it was, with what happened under it.
3973
+ const settled = !outcome.error && !outcome.alreadyResolved
3974
+ let result = outcomeText(kind, who, outcome, answer === undefined ? undefined : telegramEscape(answer))
3975
+ if (kind === 'approve' && settled) result = `**Approved by ${who}.** ${result}`
3976
+ const request = readTelegramThread(agentDirFor(entry.config), click.conversation, click.messageId).find((m) => m.ts === click.messageId)?.text
3977
+ if (request) result = `${request}\n\n${result}`
3978
+ else if (item && kind === 'approve' && settled) result += `\nRan \`${item.tool}\` with:\n${formatArgs(item.args, telegramEscape, (await resolveAgentTool(entry.config, item.tool))?.input_schema)}`
3979
+ await editTelegramMessage(botToken, click.conversation, click.messageId, result)
3980
+ }
3981
+
3982
+ // A job someone started by messaging the agent (core/messaging/jobs.ts).
3983
+ function jobDeps(agentName: string, entry: RegistryEntry, connector: MessagingConnector): JobDeps {
3984
+ const app = connector.app as TwoWayApp
3985
+ return {
3986
+ agentName,
3987
+ agentDir: agentDirFor(entry.config),
3988
+ connector,
3989
+ plan: process.env.LOOPENGINE_PLAN,
3990
+ allowed: (message) => jobAllowed(entry.config, app, message),
3991
+ runTurn: (sessionId, text) => runJobTurn(agentName, sessionId, text, connector.app === 'telegram' ? 'telegram' : 'slack'),
3992
+ decideLink: (pendingId, kind) => {
3993
+ const base = publicBaseUrl(connectionPublicUrl(entry.config, app))
3994
+ return base ? `${base}${decidePath(pendingId, kind)}` : undefined
3995
+ },
3996
+ }
3997
+ }
3998
+
3999
+ // A job thread's approvals and questions wait in the thread itself
4000
+ // (core/messaging/jobs.ts posts them), so the run's own approver only
4001
+ // hands out an id. The agent's notifier, when it has one, still sends its
4002
+ // request too, and wins: run-agent prefers it on the http channel.
4003
+ const threadApprover = {
4004
+ requestApproval: async () => false,
4005
+ requestDurableApproval: () => ({ pendingId: randomUUID() }),
4006
+ }
4007
+ const threadQuestions = {
4008
+ requestQuestion: async () => NO_HUMAN_ANSWER,
4009
+ notifyPendingQuestion: () => ({ pendingId: randomUUID() }),
4010
+ }
4011
+
4012
+ function jobTurnOf(text: string, pending: PendingSummary[] | undefined): JobTurn {
4013
+ if (!pending?.length) return { kind: 'done', text }
4014
+ return {
4015
+ kind: 'pending',
4016
+ items: pending.map((p) => ({
4017
+ pendingId: p.pendingId,
4018
+ kind: p.kind,
4019
+ tool: p.tool,
4020
+ ...(p.kind === 'question' ? { question: typeof p.args.question === 'string' ? p.args.question : p.reason } : {}),
4021
+ })),
4022
+ }
4023
+ }
4024
+
4025
+ // One turn of a job thread: the same session for the whole thread.
4026
+ async function runJobTurn(agent: string, rawSessionId: string, message: string, source: 'slack' | 'telegram'): Promise<JobTurn> {
4027
+ const entry = getEntry(agent)
4028
+ if (!entry) throw new Error(`no agent named "${agent}" in this project`)
4029
+ const tenant = 'default'
4030
+ const environment = process.env.LOOPENGINE_ENV ?? 'production'
4031
+ const outcome = await sessions.withSession(`${tenant}:${environment}:${agent}:${rawSessionId}`, async (history) => {
4032
+ const result = await runAgent(entry.config, entry.createModelCall(), message, history, {
4033
+ tenant,
4034
+ sessionId: rawSessionId,
4035
+ channel: 'http',
4036
+ approver: threadApprover,
4037
+ questionHandler: threadQuestions,
4038
+ })
4039
+ // For a host's run observer (core/run-events.ts).
4040
+ reportRun({ agent, source, id: randomUUID(), usedTool: usedTool(result.newMessages) })
4041
+ if (result.pending) await createCheckpointFromPending(agent, tenant, rawSessionId, result.pending)
4042
+ return { newMessages: result.newMessages, result: { text: result.text, pending: pendingSummary(result.pending) } }
4043
+ })
4044
+ return jobTurnOf(outcome.text, outcome.pending)
4045
+ }
4046
+
4047
+ // A resumed run that came from a job thread answers there.
4048
+ async function settleJobFromResume(agent: string, sessionId: string, text: string, pending: PendingSummary[] | undefined): Promise<void> {
4049
+ const entry = getEntry(agent)
4050
+ if (!entry) return
4051
+ const job = findJobThreadBySession(agentDirFor(entry.config), sessionId)
4052
+ if (!job) return
4053
+ let connector: MessagingConnector | undefined
4054
+ if (job.app === 'slack') {
4055
+ const { botToken } = slackCredentialsFor(entry.config)
4056
+ if (botToken) connector = createSlackConnector(botToken)
4057
+ } else if (job.app === 'telegram') {
4058
+ const { botToken } = telegramCredentialsFor(entry.config)
4059
+ if (botToken) connector = createTelegramConnector(botToken, agentDirFor(entry.config))
4060
+ }
4061
+ if (!connector) return
4062
+ await settleJobThread(jobDeps(agent, entry, connector), sessionId, jobTurnOf(text, pending))
3787
4063
  }
3788
4064
 
3789
4065
  // What core/messaging/ needs from this server to answer, decide and
3790
4066
  // change an agent's Slack requests.
3791
4067
  function slackMessagingDeps(agentName: string, entry: RegistryEntry, botToken: string): MessagingDeps {
4068
+ return messagingDeps(agentName, entry, createSlackConnector(botToken))
4069
+ }
4070
+
4071
+ // The same, for any app's connector.
4072
+ function messagingDeps(agentName: string, entry: RegistryEntry, connector: MessagingConnector): MessagingDeps {
3792
4073
  const environment = process.env.LOOPENGINE_ENV ?? 'production'
3793
4074
  return {
3794
4075
  agentName,
3795
4076
  agentDir: agentDirFor(entry.config),
3796
4077
  systemPrompt: entry.config.systemPrompt,
3797
- connector: createSlackConnector(botToken),
4078
+ connector,
3798
4079
  createModelCall: () => entry.createModelCall(),
3799
4080
  getCheckpoint: (pendingId) => checkpoints.get(pendingId),
3800
4081
  getHistory: (checkpoint) => sessions.getHistory(`${checkpoint.tenant}:${environment}:${checkpoint.agent}:${checkpoint.sessionId}`),
3801
4082
  plan: process.env.LOOPENGINE_PLAN,
3802
- approvers: () => agentApprovers(entry.config),
4083
+ approvers: () => agentApprovers(entry.config, connector.app as TwoWayApp),
3803
4084
  decide: decideFromMessaging,
3804
4085
  toolSchema: async (toolName) => (await resolveAgentTool(entry.config, toolName))?.input_schema,
3805
4086
  // The same checks the console's approve-with-edits applies, plus the
@@ -3823,6 +4104,10 @@ function slackMessagingDeps(agentName: string, entry: RegistryEntry, botToken: s
3823
4104
  // recorded through the same resolve and answer handlers as the console's
3824
4105
  // buttons.
3825
4106
  async function decideFromMessaging(pendingId: string, decision: MessagingDecision): Promise<DecisionOutcome> {
4107
+ // A run from a job thread posts its own result there once it resumes
4108
+ // (settleJobFromResume), so it isn't quoted here as well.
4109
+ const checkpoint = await checkpoints.get(pendingId)
4110
+ const fromJob = checkpoint ? Boolean(getEntry(checkpoint.agent) && findJobThreadBySession(agentDir(checkpoint.agent), checkpoint.sessionId)) : false
3826
4111
  const capture = captureResponse()
3827
4112
  if (decision.kind === 'answer') await answerPendingQuestion(capture.res, pendingId, { answer: decision.answer })
3828
4113
  else await resolvePendingApproval(capture.res, pendingId, { decision: decision.kind, ...(decision.kind === 'approve' && decision.editedArgs ? { editedArgs: decision.editedArgs } : {}) })
@@ -3831,7 +4116,7 @@ async function decideFromMessaging(pendingId: string, decision: MessagingDecisio
3831
4116
  if (body.alreadyResolved) return { alreadyResolved: true }
3832
4117
  if (body.resolved) return { stillWaiting: true }
3833
4118
  if (Array.isArray(body.pending) && body.pending.length) return { morePending: true }
3834
- return { text: typeof body.text === 'string' ? body.text : undefined }
4119
+ return { text: typeof body.text === 'string' && !fromJob ? body.text : undefined }
3835
4120
  }
3836
4121
 
3837
4122
  // ---- Webhook triggers (core/workflow-triggers.ts): an outside service
@@ -3892,6 +4177,13 @@ async function handleWorkflowHook(req: IncomingMessage, res: ServerResponse, tok
3892
4177
  }
3893
4178
  }
3894
4179
 
4180
+ // This server's address as the request reached it (behind a proxy, as
4181
+ // the proxy says), for URLs shown in the console.
4182
+ function requestBaseUrl(req: IncomingMessage): string {
4183
+ const forwardedProto = String(req.headers['x-forwarded-proto'] ?? '').split(',')[0]
4184
+ return `${forwardedProto || ((req.socket as { encrypted?: boolean }).encrypted ? 'https' : 'http')}://${String(req.headers['x-forwarded-host'] ?? req.headers.host ?? 'localhost')}`
4185
+ }
4186
+
3895
4187
  // A workflow's webhook triggers, for the console: list, make (its URL is
3896
4188
  // shown once), change, rotate (a new URL), delete.
3897
4189
  async function handleWorkflowTriggers(req: IncomingMessage, res: ServerResponse, agentName: string, workflowId: string, triggerId: string | undefined, action: string | undefined): Promise<void> {
@@ -3901,9 +4193,7 @@ async function handleWorkflowTriggers(req: IncomingMessage, res: ServerResponse,
3901
4193
  if (!getEntry(agentName)) return json(404, { error: `unknown agent '${agentName}'` })
3902
4194
  const workflow = readWorkflow(agentDir(agentName), workflowId)
3903
4195
  if (!workflow) return json(404, { error: `no workflow ${workflowId} in ${agentName}` })
3904
- const forwardedProto = String(req.headers['x-forwarded-proto'] ?? '').split(',')[0]
3905
- const base = `${forwardedProto || ((req.socket as { encrypted?: boolean }).encrypted ? 'https' : 'http')}://${String(req.headers['x-forwarded-host'] ?? req.headers.host ?? 'localhost')}`
3906
- const urlFor = (token: string) => `${base}/hooks/workflows/${token}`
4196
+ const urlFor = (token: string) => `${requestBaseUrl(req)}/hooks/workflows/${token}`
3907
4197
  try {
3908
4198
  if (!triggerId && req.method === 'GET') return json(200, { triggers: (await listTriggers(agentName, workflowId)).map(publicTrigger) })
3909
4199
  if (!triggerId && req.method === 'POST') {
@@ -3966,6 +4256,18 @@ const server = createServer(async (req, res) => {
3966
4256
  return
3967
4257
  }
3968
4258
 
4259
+ const telegramHookMatch = req.method === 'POST' && (req.url ?? '/').split('?')[0].match(/^\/hooks\/telegram\/([^/]+)$/)
4260
+ if (telegramHookMatch) {
4261
+ try {
4262
+ lastRequestAt = Date.now()
4263
+ await tracked(() => handleTelegramWebhook(req, res, decodeURIComponent(telegramHookMatch[1])))
4264
+ } catch (err) {
4265
+ console.error('[loopengine] Telegram update failed:', err)
4266
+ if (!res.headersSent) res.writeHead(500).end()
4267
+ }
4268
+ return
4269
+ }
4270
+
3969
4271
  // A workflow's webhook trigger: its token is the credential (no login).
3970
4272
  const workflowHookMatch = req.method === 'POST' && (req.url ?? '/').split('?')[0].match(/^\/hooks\/workflows\/([A-Za-z0-9_-]+)$/)
3971
4273
  if (workflowHookMatch) {
@@ -4613,6 +4915,18 @@ const server = createServer(async (req, res) => {
4613
4915
  await handleApproversPut(req, res, decodeURIComponent(approversMatch[1]))
4614
4916
  return
4615
4917
  }
4918
+ const telegramPeopleMatch = req.method === 'GET' && pathname.match(/^\/agents\/([^/]+)\/notifier\/telegram-people$/)
4919
+ if (telegramPeopleMatch) {
4920
+ const entry = getEntry(decodeURIComponent(telegramPeopleMatch[1]))
4921
+ if (!entry) res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'unknown agent' }))
4922
+ else res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ people: listAgentTelegramPeople(entry.config) }))
4923
+ return
4924
+ }
4925
+ const telegramWebhookMatch = (req.method === 'GET' || req.method === 'POST') && pathname.match(/^\/agents\/([^/]+)\/notifier\/telegram-webhook$/)
4926
+ if (telegramWebhookMatch) {
4927
+ await handleTelegramWebhookSetup(req, res, decodeURIComponent(telegramWebhookMatch[1]))
4928
+ return
4929
+ }
4616
4930
  const slackMembersMatch = req.method === 'GET' && pathname.match(/^\/agents\/([^/]+)\/notifier\/slack-members$/)
4617
4931
  if (slackMembersMatch) {
4618
4932
  await handleSlackMembers(res, decodeURIComponent(slackMembersMatch[1]))
@@ -9,7 +9,7 @@
9
9
  "cli": "tsx adapters/cli.ts"
10
10
  },
11
11
  "dependencies": {
12
- "loopengine": "^0.3.0",
12
+ "loopengine": "^0.3.4",
13
13
  "actauth": "^0.0.12",
14
14
  "tsx": "^4.19.0",
15
15
  "typescript": "^5.7.0"