create-loopengine 0.1.9 → 0.1.12

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.
@@ -1,7 +1,18 @@
1
- // Channel adapter #2: HTTP API.
1
+ // Channel adapter #2: HTTP API. Deliberately built on node:http, not a
2
+ // framework — the point is that runAgent doesn't care what's calling it,
3
+ // so the adapter has no special integration surface to show off.
2
4
  //
3
5
  // npm run dev
6
+ // # open http://localhost:8787/agents/config for the Admin UI, or
7
+ // # http://localhost:8787/playground to talk to an agent in a browser
4
8
  //
9
+ // # single JSON response, once the whole loop finishes — an agent can
10
+ // # define its own sessionIdFor (see AgentConfig.sessionIdFor) to key
11
+ // # sessions off something richer than a plain client-supplied id; the
12
+ // # default fallback takes a plain sessionId field instead, or omits it
13
+ // # entirely for a fresh one-off session (see defaultSessionIdFor
14
+ // # below) — either way the response body echoes back whichever
15
+ // # sessionId was actually used
5
16
  // curl -X POST localhost:8787/agents/example-agent/messages \
6
17
  // -H 'content-type: application/json' \
7
18
  // -d '{"message":"what is the weather in Boston?"}'
@@ -11,35 +22,173 @@
11
22
  // -H 'content-type: application/json' \
12
23
  // -d '{"message":"what is the weather in Boston?"}'
13
24
  //
14
- // sessionId is optional — omit it for a fresh, one-off session (a
15
- // generated id, echoed back in the response as sessionId so you can pass
16
- // it in explicitly on a later request to continue that same conversation).
17
- //
18
25
  // Owns: routing by agent name, request/response shape, and (via
19
26
  // SessionStore.withSession) making sure two concurrent requests for the
20
- // same session don't race on read-modify-write of that session's history.
21
- import { randomUUID } from 'node:crypto'
27
+ // *same* session don't race on read-modify-write of that session's
28
+ // history. What counts as "the same session" is deliberately not this
29
+ // file's call — see AgentConfig.sessionIdFor and defaultSessionIdFor below.
30
+ import { randomUUID, timingSafeEqual } from 'node:crypto'
22
31
  import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
23
- import { createSessionStore, runAgent, type AgentConfig } from 'loopengine'
24
- import { getEntry, type RegistryEntry } from '../agent-registry.js'
32
+ import { pathToFileURL } from 'node:url'
33
+ import { getEntry, listAgents, projectDir, registerAgent, updateAgent, type RegistryEntry } from '../agent-registry.js'
34
+ import {
35
+ loadAgentModule,
36
+ synthesizeCreateModelCall,
37
+ editAgentFile,
38
+ createHttpTool,
39
+ HttpToolNameError,
40
+ HttpToolExistsError,
41
+ HttpToolIndexShapeError,
42
+ type HttpToolSpec,
43
+ AgentEditNotSupportedError,
44
+ AgentFileNotFoundError,
45
+ type AgentEditResult,
46
+ createSessionStore,
47
+ createCheckpointStore,
48
+ type TurnCheckpoint,
49
+ runAgent,
50
+ resumeAgent,
51
+ loadRules,
52
+ loadDefaultTools,
53
+ loadSubagentAsTools,
54
+ systemTools,
55
+ systemSkillsDir,
56
+ type AgentConfig,
57
+ type ToolDefinition,
58
+ type ModelContentBlock,
59
+ type RunAgentResult,
60
+ playgroundHtml,
61
+ agentsConfigPageHtml,
62
+ agentsListPageHtml,
63
+ globalConfigPageHtml,
64
+ addGatewayTool,
65
+ agentDir,
66
+ disconnectComposioAccount,
67
+ describeGatewayTools,
68
+ listComposioConnections,
69
+ listComposioTools,
70
+ loadGatewayToolsFromDir,
71
+ removeGatewayTool,
72
+ removeGatewayToolSlug,
73
+ GatewayToolExistsError,
74
+ GatewayToolNotFoundError,
75
+ type GatewayToolEntry,
76
+ type GatewayToolDecision,
77
+ readSkill,
78
+ writeSkill,
79
+ deleteSkill,
80
+ SkillInvalidIdError,
81
+ SkillNotFoundError,
82
+ readActauthConfig,
83
+ addActauthRule,
84
+ updateActauthRule,
85
+ removeActauthRule,
86
+ setDefaultDecision,
87
+ ActauthRuleExistsError,
88
+ ActauthRuleNotFoundError,
89
+ describeModelProviders,
90
+ describeGateways,
91
+ scaffoldAgent,
92
+ AgentNameError,
93
+ AgentExistsError,
94
+ AgentModelError,
95
+ type AgentTemplateOptions,
96
+ createTrackedApprover,
97
+ listApprovals,
98
+ decideApproval,
99
+ findApproval,
100
+ listQuestions,
101
+ // answerQuestion here is the server-side resolver (aliased from
102
+ // loopengine's own resolvePendingQuestion) — not the HTTP client-side
103
+ // helper of the same name loopengine also exports for a browser/Node
104
+ // caller talking *to* this file's own routes; this file is the server,
105
+ // so it needs the former.
106
+ resolvePendingQuestion as answerQuestion,
107
+ findQuestion,
108
+ createAskUserTool,
109
+ type PendingQuestion,
110
+ WebhookNotifier,
111
+ } from 'loopengine'
112
+ import { SkillGarden } from 'skillgarden'
113
+ import type { Decision, PendingApproval } from 'actauth'
114
+ import type { LoopEvent } from 'loopengine'
25
115
 
26
116
  const sessions = createSessionStore()
117
+ const checkpoints = createCheckpointStore()
118
+
119
+ // The plain (non-streaming) POST /messages route's own built-in default
120
+ // for the 'http' channel — durable when configured, since that route
121
+ // already has no live connection of its own to hold open (see
122
+ // handleMessages' own header comment). One shared instance, not
123
+ // per-request: unlike WebchatApprover, nothing about WebhookNotifier
124
+ // needs to be scoped to a specific turn/connection — and, same "it's
125
+ // channel-specific, not concern-specific" reasoning
126
+ // core/http-notify-triggers/webhook.ts's own doc comment gives, one
127
+ // instance covers both the approval and question default, not two
128
+ // separately-configured ones (an earlier version of this file supported
129
+ // a separate LOOPENGINE_DEFAULT_QUESTION_WEBHOOK_URL/_SECRET pair for the
130
+ // question side specifically — dropped once WebhookNotifier's own
131
+ // one-target-for-both shape made that a real, not just cosmetic,
132
+ // simplification; a deployment that genuinely needs different targets
133
+ // per concern can still set AgentConfig.httpNotifier per agent instead,
134
+ // which wins outright over this default anyway). Deployment-wide env
135
+ // vars, same pattern REDIS_URL/LOOPENGINE_ADMIN_AUTH already use —
136
+ // there's no sensible webhook target to invent without one configured,
137
+ // so this is undefined (handleMessages falls back to today's live
138
+ // WebchatApprover instead, not all the way to ConsoleApprover) rather
139
+ // than guessed.
140
+ const defaultHttpWebhookUrl = process.env.LOOPENGINE_DEFAULT_WEBHOOK_URL
141
+ const defaultHttpWebhookSecret = process.env.LOOPENGINE_DEFAULT_WEBHOOK_SECRET
142
+ const defaultHttpWebhookNotifier =
143
+ defaultHttpWebhookUrl && defaultHttpWebhookSecret
144
+ ? new WebhookNotifier({ webhookUrl: defaultHttpWebhookUrl, signingSecret: defaultHttpWebhookSecret })
145
+ : undefined
27
146
 
28
- // Deriving a session key from something richer than a plain client-supplied
29
- // id (a customer's email, a Slack channel, ...) is business logic specific
30
- // to what an agent is for — see AgentConfig.sessionIdFor. This is only the
31
- // agent-agnostic fallback: a missing sessionId just means "no ongoing
32
- // conversation to resume," so one is generated rather than treated as an error.
147
+ // Used when an AgentConfig doesn't define its own sessionIdFor — a plain
148
+ // client-supplied key, same shape adapters/cli.ts's --session flag
149
+ // already uses. Deriving a session key from something richer (a
150
+ // customer's email, a Slack channel, a support ticket ID, ...) is
151
+ // business logic specific to what that agent is for, so it belongs on
152
+ // the AgentConfig, not hardcoded here for every agent this adapter might
153
+ // ever route to.
33
154
  function defaultSessionIdFor(body: Record<string, unknown>): string | undefined {
34
155
  const sessionId = body.sessionId
35
156
  return typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : undefined
36
157
  }
37
158
 
159
+ // An agent-defined sessionIdFor always wins outright when present — for
160
+ // customer-service that's a real, stable identity (a customerEmail hash),
161
+ // and it returning undefined (no customerEmail in the body) is a genuine
162
+ // validation failure, not "start me an anonymous session." Only the
163
+ // *default*, agent-agnostic fallback auto-generates one: a missing
164
+ // sessionId there just means "no ongoing conversation to resume," the
165
+ // same thing omitting --session now means for adapters/cli.ts, not an
166
+ // error — see the response body for how the caller learns what id got
167
+ // used.
38
168
  function sessionIdFor(config: AgentConfig, body: Record<string, unknown>): string | undefined {
39
169
  if (config.sessionIdFor) return config.sessionIdFor(body)
40
170
  return defaultSessionIdFor(body) ?? randomUUID()
41
171
  }
42
172
 
173
+ // Resolves AgentConfig.tenantFor against this request's headers/body —
174
+ // run-agent.ts itself never sees a request, so this per-request
175
+ // resolution has to happen here, before that point (see
176
+ // AgentConfig.tenantFor's own doc comment). Returning undefined is a real
177
+ // auth failure, not "use 'default'" — if "no header at all" should mean
178
+ // 'default' rather than a rejection, the resolver itself must return
179
+ // 'default' explicitly. No tenantFor at all is not a failure, though —
180
+ // it just means this agent doesn't need per-request tenants, so every
181
+ // request is the 'default' tenant.
182
+ function resolveTenant(
183
+ config: AgentConfig,
184
+ headers: IncomingMessage['headers'],
185
+ body: Record<string, unknown>,
186
+ ): { ok: true; value: string } | { ok: false } {
187
+ if (!config.tenantFor) return { ok: true, value: 'default' }
188
+ const result = config.tenantFor(headers, body)
189
+ return result === undefined ? { ok: false } : { ok: true, value: result }
190
+ }
191
+
43
192
  function readJsonBody(req: IncomingMessage): Promise<Record<string, unknown>> {
44
193
  return new Promise((resolve, reject) => {
45
194
  let data = ''
@@ -58,12 +207,23 @@ function readJsonBody(req: IncomingMessage): Promise<Record<string, unknown>> {
58
207
  interface ParsedRequest {
59
208
  entry: RegistryEntry
60
209
  message: string
210
+ /** Whatever the caller sent (or, for the default fallback, what got
211
+ * generated) — echo this back in the response so a client that omitted
212
+ * sessionId can capture it and resume the same conversation next time. */
61
213
  rawSessionId: string
214
+ /** The actual SessionStore key — tenant-, environment-, and agent-namespaced, never what goes in a response. */
62
215
  storageSessionId: string
216
+ /** This request's resolved tenant — pass straight through as
217
+ * RunAgentOptions.tenant on every runAgent call in this file. */
218
+ tenant: string
63
219
  }
64
220
 
65
221
  type ParseResult = { ok: true; value: ParsedRequest } | { ok: false; status: number; error: string }
66
222
 
223
+ // Shared by both routes: resolve the agent, validate the body, compute
224
+ // the session key. Neither route commits to a response shape until this
225
+ // has succeeded — the streaming route in particular must not send SSE
226
+ // headers until it knows the request is actually going to run.
67
227
  async function parseRequest(req: IncomingMessage, agentName: string): Promise<ParseResult> {
68
228
  const entry = getEntry(agentName)
69
229
  if (!entry) return { ok: false, status: 404, error: `unknown agent '${agentName}'` }
@@ -72,15 +232,827 @@ async function parseRequest(req: IncomingMessage, agentName: string): Promise<Pa
72
232
  const message = String(body.message ?? '')
73
233
  const rawSessionId = sessionIdFor(entry.config, body)
74
234
  if (!message) return { ok: false, status: 400, error: 'message is required' }
235
+ // Only reachable when the agent defines its own sessionIdFor and it
236
+ // returned undefined — the default fallback (sessionIdFor above) always
237
+ // produces something, generating one rather than leaving this undefined.
75
238
  if (!rawSessionId) return { ok: false, status: 400, error: 'could not derive a session id from the request body' }
76
239
 
77
- // Namespaced by agent name so two agents given the same sessionId don't
78
- // read/write the same underlying log.
79
- return { ok: true, value: { entry, message, rawSessionId, storageSessionId: `${agentName}:${rawSessionId}` } }
240
+ const tenantResolution = resolveTenant(entry.config, req.headers, body)
241
+ if (!tenantResolution.ok) return { ok: false, status: 401, error: 'could not verify tenant for this request' }
242
+ const tenant = tenantResolution.value
243
+
244
+ // SessionStore itself is agent-agnostic — it has no idea which agent is
245
+ // calling withSession, so two different agents given the same sessionId
246
+ // (e.g. a client that reuses one ID across agents, or two agents whose
247
+ // sessionIdFor happens to produce the same value) would otherwise read
248
+ // and write the exact same underlying log, splicing one agent's history
249
+ // into another's. Namespacing by agent name here is what actually
250
+ // prevents that — confirmed live: before this, calling file-agent then
251
+ // rag-agent with the same sessionId fed rag-agent file-agent's entire
252
+ // prior conversation as context.
253
+ //
254
+ // Also namespaced by tenant and environment, for the same reason: two
255
+ // different tenants (or environments) whose sessionIdFor happens to
256
+ // produce the same raw id would otherwise collide on the exact same
257
+ // underlying log, splicing one tenant's/environment's conversation into
258
+ // another's. environment is a deployment-wide setting (LOOPENGINE_ENV),
259
+ // not resolved per-request the way tenant is — same reasoning
260
+ // run-agent.ts's own Scope construction uses.
261
+ const environment = process.env.LOOPENGINE_ENV ?? 'production'
262
+ return {
263
+ ok: true,
264
+ value: { entry, message, rawSessionId, tenant, storageSessionId: `${tenant}:${environment}:${agentName}:${rawSessionId}` },
265
+ }
266
+ }
267
+
268
+ // Backs GET /agents/:name/config (the JSON API behind the /agents/config
269
+ // page below). Reuses loadRules/loadDefaultTools rather than re-deriving
270
+ // AgentConfig's defaults independently — so this always shows exactly what
271
+ // runAgent() would actually resolve and enforce, not a second guess at it
272
+ // that could drift out of sync. Never includes AgentModelConfig.apiKey or
273
+ // any function value (approver, isSafeTool, sessionIdFor, tenantFor) —
274
+ // those either aren't JSON-serializable or would leak a secret; each is
275
+ // reported as 'custom' vs its default instead.
276
+ //
277
+ // Includes subagents (loadSubagentAsTools) and gateway-registered tools
278
+ // (loadGatewayToolsFromDir) alongside config.tools/loadDefaultTools —
279
+ // omitting either would make this page lie about what runAgent()
280
+ // actually resolves, exactly the drift its own reasoning above is meant
281
+ // to prevent.
282
+ function describeTool(t: { name: string; description: string; safe?: boolean; input_schema: Record<string, unknown> }) {
283
+ return { name: t.name, description: t.description, safe: t.safe === true, input_schema: t.input_schema }
284
+ }
285
+
286
+ async function describeAgent(entry: RegistryEntry): Promise<Record<string, unknown>> {
287
+ const { config } = entry
288
+ // Kept separate (not just concatenated into one `tools` array — though
289
+ // that's still returned too, below, for Overview's own at-a-glance
290
+ // count) so the "Tools" tab can show where each one actually comes
291
+ // from: hand-written (agents/<name>/tools/), delegated to a subagent
292
+ // (agentAsTool), or registered through a gateway (gateway-tools.ts) —
293
+ // three different things an operator would reason about differently,
294
+ // flattened together they'd just look like an undifferentiated list.
295
+ const localTools = config.tools ?? (await loadDefaultTools(config))
296
+ const agentAsTools = await loadSubagentAsTools(config)
297
+ const gatewayTools = await loadGatewayToolsFromDir(agentDir(config.name))
298
+ const tools = [...localTools, ...agentAsTools, ...gatewayTools]
299
+ const rules = loadRules(config)
300
+ const rulesSource = Array.isArray(config.rules)
301
+ ? 'inline'
302
+ : config.rules !== undefined
303
+ ? `file: ${config.rules}`
304
+ : `default: agents/${config.name}/actauth.yml`
305
+
306
+ // Same resolution run-agent.ts's own runAgent() does for its Skill
307
+ // tool's index — reused rather than re-derived, so a "Skills" tab shows
308
+ // exactly what the next real request would actually see available, not
309
+ // a second guess (same reasoning this function's own header comment
310
+ // already gives for tools/permissions).
311
+ // Kept separate from systemSkillsDir (mirrors localTools/systemTools
312
+ // above) — system-skills/composio-large-outputs is real infrastructure
313
+ // every agent gets (see run-agent.ts), not something an operator
314
+ // configured for *this* agent, so the config page's own Skills tab
315
+ // deliberately doesn't mix it into skills/skillsDirs below.
316
+ const skillsDirs = config.skillsDirs ?? [`agents/${config.name}/skills`]
317
+ const skillGarden = new SkillGarden({ dirs: skillsDirs, indexBudgetTokens: config.skillIndexBudgetTokens ?? 200 })
318
+ const skillIndex = skillGarden.buildIndex().included
319
+ const systemSkillGarden = new SkillGarden({ dirs: [systemSkillsDir], indexBudgetTokens: config.skillIndexBudgetTokens ?? 200 })
320
+ const systemSkillIndex = systemSkillGarden.buildIndex().included
321
+
322
+ return {
323
+ name: config.name,
324
+ systemPrompt: config.systemPrompt,
325
+ model: config.model
326
+ ? { provider: config.model.provider, model: config.model.model, maxTokens: config.model.maxTokens }
327
+ : 'custom (module exports its own createModelCall)',
328
+ maxTurns: config.maxTurns ?? 25,
329
+ contextBudgetTokens: config.contextBudgetTokens ?? 8000,
330
+ skillIndexBudgetTokens: config.skillIndexBudgetTokens ?? 200,
331
+ skillsDirs: skillsDirs,
332
+ skills: skillIndex.map((s) => ({ name: s.name, description: s.description })),
333
+ systemSkills: systemSkillIndex.map((s) => ({ name: s.name, description: s.description })),
334
+ tools: tools.map(describeTool),
335
+ // createAskUserTool() with no onPending, purely to describe its
336
+ // schema here — never executed, so the console-prompt fallback its
337
+ // own doc comment mentions never applies to this call.
338
+ systemTools: [...systemTools, createAskUserTool({ agent: config.name })].map(describeTool),
339
+ localTools: localTools.map(describeTool),
340
+ agentAsTools: agentAsTools.map(describeTool),
341
+ gatewayTools: gatewayTools.map(describeTool),
342
+ permissions: {
343
+ source: rulesSource,
344
+ defaultDecision: rules.defaultDecision,
345
+ rules: rules.rules,
346
+ },
347
+ isSafeTool: config.isSafeTool ? 'custom' : "default (each tool's own `safe` flag)",
348
+ sessionIdFor: config.sessionIdFor ? 'custom' : 'default (client-supplied `sessionId` field)',
349
+ tenantFor: config.tenantFor ? 'custom' : "none (every request is the 'default' tenant)",
350
+ }
351
+ }
352
+
353
+ // Backs POST /agents/:name/gateway-tools — validates the body into a
354
+ // GatewayToolEntry and hands it to gateway-tools.ts's addGatewayTool. Only
355
+ // 'composio' is accepted today (gateway-tools.ts's own GatewayToolEntry
356
+ // union is exactly that narrow); a second provider means one more arm
357
+ // here, not a rewrite.
358
+ function parseGatewayToolEntry(body: Record<string, unknown>): { ok: true; value: GatewayToolEntry } | { ok: false; error: string } {
359
+ if (body.provider !== 'composio') return { ok: false, error: `unsupported provider '${String(body.provider)}' — only 'composio' today` }
360
+ if (typeof body.name !== 'string' || !body.name) return { ok: false, error: 'name is required' }
361
+ if (!Array.isArray(body.slugs) || body.slugs.length === 0 || !body.slugs.every((s) => typeof s === 'string' && s)) {
362
+ return { ok: false, error: 'slugs must be a non-empty array of strings' }
363
+ }
364
+ const entry: GatewayToolEntry = { provider: 'composio', name: body.name, slugs: body.slugs as string[] }
365
+ if (typeof body.cliCommand === 'string' && body.cliCommand) entry.cliCommand = body.cliCommand
366
+ return { ok: true, value: entry }
367
+ }
368
+
369
+ function isDecision(value: unknown): value is Decision {
370
+ return value === 'allow' || value === 'ask' || value === 'deny'
371
+ }
372
+
373
+ // Only the gateway-tools add route accepts 'auto' — actauth rules
374
+ // themselves (handleActauthRulePost/Put, handleActauthDefaultDecisionPut)
375
+ // still require a real Decision, since 'auto' only makes sense as "figure
376
+ // out a decision per tool for this batch," not as a value a single rule
377
+ // or default_decision could itself hold.
378
+ function isGatewayToolDecision(value: unknown): value is GatewayToolDecision {
379
+ return isDecision(value) || value === 'auto'
380
+ }
381
+
382
+ async function handleToolSourcesGet(res: ServerResponse, agentName: string, force: boolean): Promise<void> {
383
+ if (!getEntry(agentName)) {
384
+ res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
385
+ return
386
+ }
387
+ const sources = await describeGatewayTools(agentName, { force })
388
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ sources }))
389
+ }
390
+
391
+ async function handleToolSourcesPost(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
392
+ if (!getEntry(agentName)) {
393
+ res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
394
+ return
395
+ }
396
+ const body = await readJsonBody(req)
397
+ const parsed = parseGatewayToolEntry(body)
398
+ if (!parsed.ok) {
399
+ res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: parsed.error }))
400
+ return
401
+ }
402
+ const decision = isGatewayToolDecision(body.decision) ? body.decision : undefined
403
+ try {
404
+ addGatewayTool(agentName, parsed.value, decision)
405
+ } catch (err) {
406
+ const status = err instanceof GatewayToolExistsError ? 409 : 500
407
+ res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
408
+ return
409
+ }
410
+ const sources = await describeGatewayTools(agentName)
411
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ sources }))
412
+ }
413
+
414
+ function handleToolSourcesDelete(res: ServerResponse, agentName: string, sourceName: string): void {
415
+ if (!getEntry(agentName)) {
416
+ res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
417
+ return
418
+ }
419
+ try {
420
+ removeGatewayTool(agentName, sourceName)
421
+ } catch (err) {
422
+ const status = err instanceof GatewayToolNotFoundError ? 404 : 500
423
+ res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
424
+ return
425
+ }
426
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true }))
427
+ }
428
+
429
+ // Backs the per-tool remove icon in the Tools tab's Gateway Tools list
430
+ // (as opposed to handleToolSourcesDelete above, which drops a whole
431
+ // source). Returns the freshly-resolved sources, same as
432
+ // handleToolSourcesPost — so the frontend can apply the result directly
433
+ // instead of making a second, redundant GET for the same data.
434
+ async function handleGatewayToolSlugDelete(res: ServerResponse, agentName: string, sourceName: string, slug: string): Promise<void> {
435
+ if (!getEntry(agentName)) {
436
+ res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
437
+ return
438
+ }
439
+ try {
440
+ removeGatewayToolSlug(agentName, sourceName, slug)
441
+ } catch (err) {
442
+ const status = err instanceof GatewayToolNotFoundError ? 404 : 500
443
+ res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
444
+ return
445
+ }
446
+ const sources = await describeGatewayTools(agentName)
447
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ sources }))
448
+ }
449
+
450
+ function parseHttpToolBody(body: Record<string, unknown>): { ok: true; value: HttpToolSpec; decision?: Decision } | { ok: false; error: string } {
451
+ if (typeof body.name !== 'string' || !body.name) return { ok: false, error: 'name is required' }
452
+ if (typeof body.description !== 'string' || !body.description) return { ok: false, error: 'description is required' }
453
+ if (!Array.isArray(body.fields) || !body.fields.every((f) => f && typeof f === 'object')) {
454
+ return { ok: false, error: 'fields must be an array' }
455
+ }
456
+ const fields = body.fields as Record<string, unknown>[]
457
+ for (const f of fields) {
458
+ if (typeof f.name !== 'string' || !f.name) return { ok: false, error: 'every field needs a name' }
459
+ if (f.type !== 'string' && f.type !== 'number' && f.type !== 'boolean') return { ok: false, error: `field "${f.name}" has an invalid type` }
460
+ }
461
+ const method = body.method
462
+ if (method !== 'GET' && method !== 'POST' && method !== 'PUT' && method !== 'PATCH' && method !== 'DELETE') {
463
+ return { ok: false, error: 'method must be one of GET, POST, PUT, PATCH, DELETE' }
464
+ }
465
+ if (typeof body.url !== 'string' || !body.url) return { ok: false, error: 'url is required' }
466
+ if (!Array.isArray(body.headers) || !body.headers.every((h) => h && typeof h === 'object' && typeof (h as Record<string, unknown>).key === 'string' && typeof (h as Record<string, unknown>).value === 'string')) {
467
+ return { ok: false, error: 'headers must be an array of {key, value} strings' }
468
+ }
469
+ if (body.responseJsonPath !== undefined && typeof body.responseJsonPath !== 'string') {
470
+ return { ok: false, error: 'responseJsonPath must be a string' }
471
+ }
472
+ if (body.decision !== undefined && !isDecision(body.decision)) {
473
+ return { ok: false, error: 'decision must be allow, ask, or deny' }
474
+ }
475
+
476
+ return {
477
+ ok: true,
478
+ value: {
479
+ name: body.name,
480
+ description: body.description,
481
+ fields: fields as unknown as HttpToolSpec['fields'],
482
+ method,
483
+ url: body.url,
484
+ headers: body.headers as HttpToolSpec['headers'],
485
+ sendFieldsAsJsonBody: body.sendFieldsAsJsonBody === true,
486
+ responseJsonPath: body.responseJsonPath as string | undefined,
487
+ },
488
+ decision: body.decision as Decision | undefined,
489
+ }
490
+ }
491
+
492
+ // Backs the Tools tab's "Create an HTTP tool" form — generates a real
493
+ // agents/:name/tools/<tool>.ts file (loopengine's own createHttpTool),
494
+ // then splices the freshly-imported ToolDefinition straight into this
495
+ // process's own live registry via updateAgent (../agent-registry.js) —
496
+ // the same "no restart needed" mechanism a newly-created agent already
497
+ // gets (see registerAgent's own doc comment), and deliberately not a
498
+ // reliance on tsx watch picking up the new file: this way the tool is
499
+ // callable immediately under `serve` (no watch) too, not just `dev`. An
500
+ // optional `decision` seeds one actauth.yml rule via the already-existing
501
+ // addActauthRule, mirroring gateway-tools.ts's own "a rule is opt-in,
502
+ // omitting it falls through to default_decision" precedent — never a
503
+ // hardcoded default here.
504
+ async function handleHttpToolPost(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
505
+ const entry = getEntry(agentName)
506
+ if (!entry) {
507
+ res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
508
+ return
509
+ }
510
+ const body = await readJsonBody(req)
511
+ const parsed = parseHttpToolBody(body)
512
+ if (!parsed.ok) {
513
+ res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: parsed.error }))
514
+ return
515
+ }
516
+
517
+ let tool: ToolDefinition
518
+ try {
519
+ const result = await createHttpTool(agentName, parsed.value)
520
+ tool = result.tool
521
+ } catch (err) {
522
+ const status = err instanceof HttpToolExistsError ? 409 : err instanceof HttpToolNameError || err instanceof HttpToolIndexShapeError ? 400 : 500
523
+ res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
524
+ return
525
+ }
526
+
527
+ const currentTools = entry.config.tools ?? (await loadDefaultTools(entry.config))
528
+ updateAgent(agentName, { config: { tools: [...currentTools, tool] } })
529
+
530
+ if (parsed.decision) {
531
+ try {
532
+ addActauthRule(agentName, { name: `${parsed.value.name}-admin-created`, scope: '*/*', tool: parsed.value.name, decision: parsed.decision })
533
+ } catch (err) {
534
+ // The tool file and live registration above already succeeded —
535
+ // a rule-seeding failure (near-impossible: only real cause is a
536
+ // same-named rule already existing) shouldn't roll either back,
537
+ // just surface as a warning-shaped success so the operator can
538
+ // add the rule by hand via the Actauth tab instead.
539
+ res.writeHead(200, { 'content-type': 'application/json' }).end(
540
+ JSON.stringify({ tool: describeTool(tool), ruleWarning: err instanceof Error ? err.message : String(err) }),
541
+ )
542
+ return
543
+ }
544
+ }
545
+
546
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ tool: describeTool(tool) }))
547
+ }
548
+
549
+ // Backs GET /composio/connections and GET /composio/tools — not scoped
550
+ // under /agents/:name/ like the routes above, deliberately: which apps
551
+ // are connected and what they offer isn't a property of any one agent,
552
+ // it's whatever Composio account is authenticated on this machine (via
553
+ // `composio link <toolkit>`), the same one every agent's gateway tools
554
+ // already draw from. These two back the add-a-source picker in
555
+ // web/agents-config-page.ts's Gateway Tools section — see
556
+ // listComposioConnections/listComposioTools's own doc comments for why
557
+ // this doesn't need any extra setup beyond that.
558
+ async function handleComposioConnections(res: ServerResponse): Promise<void> {
559
+ try {
560
+ const connections = await listComposioConnections()
561
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ connections }))
562
+ } catch (err) {
563
+ res.writeHead(502, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
564
+ }
80
565
  }
81
566
 
82
- function writeSseEvent(res: ServerResponse, event: string, data: unknown): void {
83
- res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
567
+ async function handleComposioTools(res: ServerResponse, toolkit: string | undefined): Promise<void> {
568
+ if (!toolkit) {
569
+ res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'toolkit query parameter is required' }))
570
+ return
571
+ }
572
+ try {
573
+ const tools = await listComposioTools(toolkit)
574
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ tools }))
575
+ } catch (err) {
576
+ res.writeHead(502, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
577
+ }
578
+ }
579
+
580
+ // Backs the Gateways panel's own Disconnect control — see
581
+ // disconnectComposioAccount's own doc comment for why there's no
582
+ // Connect counterpart (composio login has no non-interactive path that
583
+ // actually matches a credential an operator has on hand). Returns the
584
+ // freshly-resolved gateways list on success, same "apply the response
585
+ // directly instead of a second GET" pattern the gateway-tools routes
586
+ // already use.
587
+ async function handleComposioDisconnect(res: ServerResponse): Promise<void> {
588
+ try {
589
+ await disconnectComposioAccount()
590
+ } catch (err) {
591
+ res.writeHead(502, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
592
+ return
593
+ }
594
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(await describeGateways()))
595
+ }
596
+
597
+ // Backs the agents list page's "Create new agent" button — reuses
598
+ // bin/cli.ts's own scaffoldAgent rather than re-implementing the
599
+ // agents/<name>/index.ts template here, so a web-created agent is
600
+ // byte-for-byte the same stub `loopengine add-agent` would generate.
601
+ // scaffoldAgent runs against core/agent-registry.ts's own projectDir(), not
602
+ // process.cwd() — they're usually the same directory, but only
603
+ // projectDir() is guaranteed to match where this registry's own
604
+ // discoverAgents call actually resolved agents/ against (see its own
605
+ // doc comment), which is what matters here.
606
+ //
607
+ // Unlike core/agent-registry.ts's own discoverAgents (a one-shot directory
608
+ // scan at process startup — see that module's header comment), this
609
+ // loads and registers the new agent into *this* running process
610
+ // immediately: loadAgentModule imports just the one new file (the same
611
+ // primitive discoverAgents itself uses per-entry), and registerAgent
612
+ // adds it to the live registry, in place. No restart needed — this is
613
+ // the one legitimate case for it: a module that was never imported
614
+ // before has nothing to invalidate or go stale, unlike hot-reloading an
615
+ // *existing* agent's already-imported code would (Node's ESM loader
616
+ // caches a given module forever; there's no supported way to safely
617
+ // re-import a changed one without a real restart). If loading/
618
+ // registering the fresh file fails for some reason (a bug in the
619
+ // generated template, an extremely unlikely name race), the file is
620
+ // still there on disk — reported as `registered: false` rather than
621
+ // treated as the whole request failing, since scaffolding did succeed;
622
+ // a restart (or `loopengine dev`'s own auto-restart on new
623
+ // agents/*/index.ts files — see bin/cli.ts's own comment there) would still
624
+ // pick it up the normal way.
625
+ // systemPrompt/model are both optional in the request body — see
626
+ // AgentTemplateOptions' own doc comment (via agentIndexTemplate) for the
627
+ // defaults scaffoldAgent falls back to when either is omitted.
628
+ function parseAgentTemplateOptions(body: Record<string, unknown>): { ok: true; value: AgentTemplateOptions } | { ok: false; error: string } {
629
+ const options: AgentTemplateOptions = {}
630
+ if (typeof body.systemPrompt === 'string' && body.systemPrompt.trim()) {
631
+ options.systemPrompt = body.systemPrompt
632
+ }
633
+ if (body.model !== undefined && body.model !== null) {
634
+ if (typeof body.model !== 'object') {
635
+ return { ok: false, error: 'model must be an object' }
636
+ }
637
+ const provider = (body.model as Record<string, unknown>).provider
638
+ if (provider !== 'anthropic' && provider !== 'openai' && provider !== 'deepseek') {
639
+ return { ok: false, error: `unsupported model provider '${String(provider)}'` }
640
+ }
641
+ const modelName = (body.model as Record<string, unknown>).model
642
+ options.model = { provider, model: typeof modelName === 'string' ? modelName : undefined }
643
+ }
644
+ return { ok: true, value: options }
645
+ }
646
+
647
+ async function handleCreateAgent(req: IncomingMessage, res: ServerResponse): Promise<void> {
648
+ const body = await readJsonBody(req)
649
+ if (typeof body.name !== 'string' || !body.name) {
650
+ res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'name is required' }))
651
+ return
652
+ }
653
+ const parsedOptions = parseAgentTemplateOptions(body)
654
+ if (!parsedOptions.ok) {
655
+ res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: parsedOptions.error }))
656
+ return
657
+ }
658
+
659
+ let indexPath: string
660
+ try {
661
+ indexPath = await scaffoldAgent(projectDir(), body.name, parsedOptions.value)
662
+ } catch (err) {
663
+ const status = err instanceof AgentNameError || err instanceof AgentModelError ? 400 : err instanceof AgentExistsError ? 409 : 500
664
+ res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
665
+ return
666
+ }
667
+
668
+ try {
669
+ registerAgent(await loadAgentModule(indexPath, indexPath))
670
+ } catch (err) {
671
+ res
672
+ .writeHead(200, { 'content-type': 'application/json' })
673
+ .end(JSON.stringify({ path: indexPath, registered: false, error: err instanceof Error ? err.message : String(err) }))
674
+ return
675
+ }
676
+
677
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ path: indexPath, registered: true }))
678
+ }
679
+
680
+ // Backs the Overview tab's System prompt / Model edit forms — either
681
+ // field can be sent independently (see parseAgentTemplateOptions), the
682
+ // other stays untouched both on disk and live. Persists via
683
+ // agent-file-admin.ts's editAgentFile (see its own doc comment for why
684
+ // this is a surgical AST edit, not a full file regeneration), then
685
+ // applies the exact same resolved values to *this* running process via
686
+ // core/agent-registry.ts's updateAgent — a model change also needs a fresh
687
+ // createModelCall (see synthesizeCreateModelCall's own doc comment for
688
+ // why that's regenerable on its own, unlike the rest of an already-
689
+ // imported module). No restart needed, same as every other admin edit
690
+ // in this app.
691
+ async function handleEditAgent(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
692
+ if (!getEntry(agentName)) {
693
+ res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
694
+ return
695
+ }
696
+ const body = await readJsonBody(req)
697
+ const parsedOptions = parseAgentTemplateOptions(body)
698
+ if (!parsedOptions.ok) {
699
+ res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: parsedOptions.error }))
700
+ return
701
+ }
702
+ if (parsedOptions.value.systemPrompt === undefined && parsedOptions.value.model === undefined) {
703
+ res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'systemPrompt or model is required' }))
704
+ return
705
+ }
706
+
707
+ let result: AgentEditResult
708
+ try {
709
+ result = editAgentFile(agentName, parsedOptions.value)
710
+ } catch (err) {
711
+ const status =
712
+ err instanceof AgentEditNotSupportedError || err instanceof AgentModelError ? 400 : err instanceof AgentFileNotFoundError ? 404 : 500
713
+ res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
714
+ return
715
+ }
716
+
717
+ const configPatch: Partial<AgentConfig> = {}
718
+ if (result.systemPrompt !== undefined) configPatch.systemPrompt = result.systemPrompt
719
+ let createModelCall: RegistryEntry['createModelCall'] | undefined
720
+ if (result.model) {
721
+ configPatch.model = result.model
722
+ createModelCall = await synthesizeCreateModelCall(result.model)
723
+ }
724
+ updateAgent(agentName, { config: configPatch, createModelCall })
725
+
726
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(await describeAgent(getEntry(agentName)!)))
727
+ }
728
+
729
+ // Backs the Skills tab's edit form (GET .../skills/:skillId to populate
730
+ // it, PUT to save, DELETE to remove) — see skills-admin.ts's own doc
731
+ // comment for why this only reaches flat (non-nested) skills.
732
+ function handleSkillGet(res: ServerResponse, agentName: string, skillId: string): void {
733
+ if (!getEntry(agentName)) {
734
+ res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
735
+ return
736
+ }
737
+ try {
738
+ const skill = readSkill(agentName, skillId)
739
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(skill))
740
+ } catch (err) {
741
+ const status = err instanceof SkillNotFoundError ? 404 : err instanceof SkillInvalidIdError ? 400 : 500
742
+ res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
743
+ }
744
+ }
745
+
746
+ async function handleSkillPut(req: IncomingMessage, res: ServerResponse, agentName: string, skillId: string): Promise<void> {
747
+ if (!getEntry(agentName)) {
748
+ res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
749
+ return
750
+ }
751
+ const body = await readJsonBody(req)
752
+ if (typeof body.description !== 'string' || !body.description) {
753
+ res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'description is required' }))
754
+ return
755
+ }
756
+ if (typeof body.body !== 'string' || !body.body) {
757
+ res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'body is required' }))
758
+ return
759
+ }
760
+ try {
761
+ writeSkill(agentName, skillId, { description: body.description, body: body.body })
762
+ } catch (err) {
763
+ const status = err instanceof SkillInvalidIdError ? 400 : 500
764
+ res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
765
+ return
766
+ }
767
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true }))
768
+ }
769
+
770
+ function handleSkillDelete(res: ServerResponse, agentName: string, skillId: string): void {
771
+ if (!getEntry(agentName)) {
772
+ res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
773
+ return
774
+ }
775
+ try {
776
+ deleteSkill(agentName, skillId)
777
+ } catch (err) {
778
+ const status = err instanceof SkillNotFoundError ? 404 : err instanceof SkillInvalidIdError ? 400 : 500
779
+ res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
780
+ return
781
+ }
782
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true }))
783
+ }
784
+
785
+ // Backs the Actauth tab's rule editor — parses a rule body shared by both
786
+ // the add (POST) and update (PUT, minus `name`) routes below.
787
+ function parseActauthRuleBody(body: Record<string, unknown>, requireName: boolean): { ok: true; value: { name: string; scope: string; tool: string; decision: Decision } } | { ok: false; error: string } {
788
+ if (requireName && (typeof body.name !== 'string' || !body.name)) return { ok: false, error: 'name is required' }
789
+ if (typeof body.scope !== 'string' || !body.scope) return { ok: false, error: 'scope is required' }
790
+ if (typeof body.tool !== 'string' || !body.tool) return { ok: false, error: 'tool is required' }
791
+ if (!isDecision(body.decision)) return { ok: false, error: "decision must be 'allow', 'ask', or 'deny'" }
792
+ return { ok: true, value: { name: typeof body.name === 'string' ? body.name : '', scope: body.scope, tool: body.tool, decision: body.decision } }
793
+ }
794
+
795
+ function handleActauthGet(res: ServerResponse, agentName: string): void {
796
+ if (!getEntry(agentName)) {
797
+ res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
798
+ return
799
+ }
800
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(readActauthConfig(agentName)))
801
+ }
802
+
803
+ async function handleActauthRulePost(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
804
+ if (!getEntry(agentName)) {
805
+ res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
806
+ return
807
+ }
808
+ const body = await readJsonBody(req)
809
+ const parsed = parseActauthRuleBody(body, true)
810
+ if (!parsed.ok) {
811
+ res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: parsed.error }))
812
+ return
813
+ }
814
+ try {
815
+ addActauthRule(agentName, parsed.value)
816
+ } catch (err) {
817
+ const status = err instanceof ActauthRuleExistsError ? 409 : 500
818
+ res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
819
+ return
820
+ }
821
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(readActauthConfig(agentName)))
822
+ }
823
+
824
+ async function handleActauthRulePut(req: IncomingMessage, res: ServerResponse, agentName: string, ruleName: string): Promise<void> {
825
+ if (!getEntry(agentName)) {
826
+ res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
827
+ return
828
+ }
829
+ const body = await readJsonBody(req)
830
+ const parsed = parseActauthRuleBody(body, false)
831
+ if (!parsed.ok) {
832
+ res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: parsed.error }))
833
+ return
834
+ }
835
+ try {
836
+ updateActauthRule(agentName, ruleName, parsed.value)
837
+ } catch (err) {
838
+ const status = err instanceof ActauthRuleNotFoundError ? 404 : 500
839
+ res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
840
+ return
841
+ }
842
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(readActauthConfig(agentName)))
843
+ }
844
+
845
+ function handleActauthRuleDelete(res: ServerResponse, agentName: string, ruleName: string): void {
846
+ if (!getEntry(agentName)) {
847
+ res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
848
+ return
849
+ }
850
+ try {
851
+ removeActauthRule(agentName, ruleName)
852
+ } catch (err) {
853
+ const status = err instanceof ActauthRuleNotFoundError ? 404 : 500
854
+ res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
855
+ return
856
+ }
857
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(readActauthConfig(agentName)))
858
+ }
859
+
860
+ async function handleActauthDefaultDecisionPut(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
861
+ if (!getEntry(agentName)) {
862
+ res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
863
+ return
864
+ }
865
+ const body = await readJsonBody(req)
866
+ if (!isDecision(body.decision)) {
867
+ res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: "decision must be 'allow', 'ask', or 'deny'" }))
868
+ return
869
+ }
870
+ setDefaultDecision(agentName, body.decision)
871
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(readActauthConfig(agentName)))
872
+ }
873
+
874
+ // GET /agents content-negotiates on this: a browser navigating there sends
875
+ // an Accept header that prefers text/html, so it gets agentsListPageHtml
876
+ // below; fetch()'s own default Accept (`*/*`, the same default every
877
+ // fetch('/agents') call in playground.ts/agents-config-page.ts/
878
+ // agents-list-page.ts itself already relies on) does not include
879
+ // text/html, so this can't silently break an existing JSON consumer.
880
+ function prefersHtml(req: IncomingMessage): boolean {
881
+ return (req.headers.accept ?? '').includes('text/html')
882
+ }
883
+
884
+ // The event's own `type` field is both the SSE event name and the
885
+ // serialized payload — see loop-events.ts's own header comment for why a
886
+ // wire frame is never "name + unrelated detail" the way it used to be.
887
+ function writeSseEvent(res: ServerResponse, event: LoopEvent): void {
888
+ res.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`)
889
+ }
890
+
891
+ // Backs GET /agents/:name/sessions/:id — the playground's "resume a past
892
+ // conversation" sidebar rehydrating the chat pane after a page refresh
893
+ // lost its in-memory sessionId. rawSessionId is exactly what a `session`
894
+ // SSE event (see handleMessagesStream) echoed back earlier, so
895
+ // reconstructing the same storageSessionId parseRequest computes from it
896
+ // (tenant/environment/agentName-namespaced — see its own doc comment for
897
+ // why) finds the same underlying log. Tenant resolution has no request
898
+ // body to work with here (a GET has none) — fine for the common case
899
+ // (no custom tenantFor, or one that only reads headers), but an agent
900
+ // whose tenantFor depends on the message body can't be resolved this way.
901
+ async function handleSessionGet(req: IncomingMessage, res: ServerResponse, agentName: string, rawSessionId: string): Promise<void> {
902
+ const entry = getEntry(agentName)
903
+ if (!entry) {
904
+ res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
905
+ return
906
+ }
907
+ const tenantResolution = resolveTenant(entry.config, req.headers, {})
908
+ if (!tenantResolution.ok) {
909
+ res.writeHead(401, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'could not verify tenant for this request' }))
910
+ return
911
+ }
912
+ const environment = process.env.LOOPENGINE_ENV ?? 'production'
913
+ const storageSessionId = `${tenantResolution.value}:${environment}:${agentName}:${rawSessionId}`
914
+ const history = await sessions.getHistory(storageSessionId)
915
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ sessionId: rawSessionId, history }))
916
+ }
917
+
918
+ type PendingSignal = { type: 'question'; entry: PendingQuestion } | { type: 'approval'; entry: PendingApproval }
919
+ type FinishedTurn = { text: string; stopReason?: 'max_turns' | 'denied' | 'pending_approval' | 'pending_question' }
920
+
921
+ // One entry per turn currently in flight for the plain (non-streaming)
922
+ // /messages route, keyed by "<agentName>:<rawSessionId>" — the same
923
+ // (agent, sessionId) granularity every other pending-item lookup in this
924
+ // file already uses (see listApprovals/listQuestions's own filters),
925
+ // not a stricter per-tenant key: a custom rawSessionId reused across two
926
+ // tenants for the same agent was already ambiguous before this existed.
927
+ // This is what makes POST /approvals/:id/approve|deny and
928
+ // POST /questions/:id/answer able to report "what's next" (another
929
+ // pending item, or the final result) instead of just "ok" — see
930
+ // raceAndRespond below for the shared logic, and its own doc comment for
931
+ // why a fresh Promise per race isn't enough on its own once a *second*
932
+ // pending item can arrive after the first is already decided.
933
+ interface SessionTurnState {
934
+ turnPromise: Promise<FinishedTurn> | null
935
+ // At most one of these is ever non-null at a time: a signal that
936
+ // arrived before anyone asked for it (bufferedSignal), or a resolver
937
+ // waiting for one that hasn't arrived yet (waiter) — never both, since
938
+ // pushSignal always drains whichever waiter is currently registered
939
+ // the instant a signal shows up, and nextSignal always drains the
940
+ // buffer the instant something asks. Only one item can genuinely be
941
+ // pending at a time per session regardless (run-agent.ts's own gate
942
+ // loop evaluates — and so can only ever be blocked on — one tool call
943
+ // at a time), so a single slot is enough; no queue needed.
944
+ bufferedSignal: PendingSignal | null
945
+ waiter: ((signal: PendingSignal) => void) | null
946
+ // Every LoopEvent this turn has produced so far, in order — populated
947
+ // identically whether this state backs a streamed or a plain turn (see
948
+ // both handleMessages/handleMessagesStream below). The streaming route
949
+ // already delivers each one live over SSE and has no direct use for the
950
+ // array itself, but keeps it anyway: a decide/answer call that arrives
951
+ // after that turn's own SSE connection is long gone (see this
952
+ // interface's own header comment) still resumes via raceAndRespond,
953
+ // whose final response should report the same full transcript a plain
954
+ // turn's response would, not an empty one. The plain (non-streaming)
955
+ // /messages route is what actually reads this on the way out — see
956
+ // pendingResponseBody/raceAndRespond below — since it has no live
957
+ // channel to have delivered any of this already.
958
+ events: LoopEvent[]
959
+ }
960
+
961
+ const sessionTurns = new Map<string, SessionTurnState>()
962
+
963
+ function turnKey(agentName: string, rawSessionId: string): string {
964
+ return `${agentName}:${rawSessionId}`
965
+ }
966
+
967
+ function pushSignal(state: SessionTurnState, signal: PendingSignal): void {
968
+ if (state.waiter) {
969
+ const waiter = state.waiter
970
+ state.waiter = null
971
+ waiter(signal)
972
+ } else {
973
+ state.bufferedSignal = signal
974
+ }
975
+ }
976
+
977
+ function nextSignal(state: SessionTurnState): Promise<PendingSignal> {
978
+ if (state.bufferedSignal) {
979
+ const signal = state.bufferedSignal
980
+ state.bufferedSignal = null
981
+ return Promise.resolve(signal)
982
+ }
983
+ return new Promise((resolve) => {
984
+ state.waiter = resolve
985
+ })
986
+ }
987
+
988
+ function pendingResponseBody(rawSessionId: string, signal: PendingSignal, events: LoopEvent[]): Record<string, unknown> {
989
+ const { type, entry } = signal
990
+ return type === 'question'
991
+ ? {
992
+ pending: true,
993
+ type,
994
+ id: entry.id,
995
+ sessionId: rawSessionId,
996
+ question: entry.question,
997
+ options: entry.options,
998
+ answerUrl: `/questions/${entry.id}/answer`,
999
+ events,
1000
+ }
1001
+ : {
1002
+ pending: true,
1003
+ type,
1004
+ id: entry.id,
1005
+ sessionId: rawSessionId,
1006
+ tool: entry.tool,
1007
+ args: entry.args,
1008
+ // Already on PendingApproval itself (actauth's own Scope) — the
1009
+ // playground's appendApprovalCard reads data.scope.tenant/
1010
+ // environment/agent directly, live or resumed alike, so this was
1011
+ // missing here even before raceAndRespond was reused for the
1012
+ // decide/answer routes too; just never exercised until a resumed
1013
+ // card's own decision started rendering the next one straight
1014
+ // from this body instead of a fresh /approvals fetch (which does
1015
+ // include it).
1016
+ scope: entry.scope,
1017
+ reason: entry.reason,
1018
+ approveUrl: `/approvals/${entry.id}/approve`,
1019
+ denyUrl: `/approvals/${entry.id}/deny`,
1020
+ events,
1021
+ }
1022
+ }
1023
+
1024
+ // The one place that actually answers "what should the HTTP response be
1025
+ // right now" for this whole pending-item family — POST /messages calls it
1026
+ // once the turn starts; POST .../approve|deny|answer call it again after
1027
+ // resolving their own one decision, to report whatever comes next instead
1028
+ // of a bare {ok: true} that leaves the caller polling. Every one of these
1029
+ // routes ends up returning exactly one of the same two shapes: 202
1030
+ // {pending: true, ...} if the turn immediately needs another decision, or
1031
+ // 200 {text, sessionId, stopReason?} if it's actually done — a caller can
1032
+ // treat the whole family as one uniform "decide-or-finish" loop regardless
1033
+ // of which endpoint the response came from.
1034
+ async function raceAndRespond(res: ServerResponse, agentName: string, rawSessionId: string, state: SessionTurnState): Promise<void> {
1035
+ const winner = await Promise.race([
1036
+ state.turnPromise!.then((turnResult) => ({ kind: 'done' as const, turnResult })),
1037
+ nextSignal(state).then((signal) => ({ kind: 'pending' as const, signal })),
1038
+ ])
1039
+
1040
+ if (winner.kind === 'pending') {
1041
+ res.writeHead(202, { 'content-type': 'application/json' }).end(
1042
+ JSON.stringify({ ...pendingResponseBody(rawSessionId, winner.signal, state.events), statusUrl: `/agents/${agentName}/sessions/${rawSessionId}` }),
1043
+ )
1044
+ return
1045
+ }
1046
+
1047
+ // events: the full typed lifecycle of this turn, start to finish — see
1048
+ // SessionTurnState.events's own doc comment. Both the plain and
1049
+ // streaming routes populate it identically; this is what makes a plain
1050
+ // POST /messages response a real, complete projection of the same
1051
+ // lifecycle the streaming route delivers frame-by-frame, not just a
1052
+ // final-text summary of it.
1053
+ const responseBody: Record<string, unknown> = { text: winner.turnResult.text, sessionId: rawSessionId, events: state.events }
1054
+ if (winner.turnResult.stopReason) responseBody.stopReason = winner.turnResult.stopReason
1055
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(responseBody))
84
1056
  }
85
1057
 
86
1058
  async function handleMessages(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
@@ -89,55 +1061,884 @@ async function handleMessages(req: IncomingMessage, res: ServerResponse, agentNa
89
1061
  res.writeHead(parsed.status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: parsed.error }))
90
1062
  return
91
1063
  }
92
- const { entry, message, rawSessionId, storageSessionId } = parsed.value
1064
+ const { entry, message, rawSessionId, storageSessionId, tenant } = parsed.value
1065
+
1066
+ // This route has no live channel of its own — no SSE connection to push
1067
+ // a pending question/approval onto the way the streaming route does.
1068
+ // For a tool-call approval, that's exactly why its channel default is
1069
+ // durable (see defaultHttpWebhookNotifier above): the turn just returns
1070
+ // stopReason 'pending_approval' and this responds with that directly,
1071
+ // no live wait at all. question:pending gets the same durable default
1072
+ // now too (see defaultHttpWebhookNotifier's own use below and
1073
+ // HUMAN_IN_THE_LOOP.md's "Durable questions" section) — but either kind
1074
+ // can still fall through to the live path (unconfigured webhook, or an
1075
+ // agent that explicitly opts into a live approver for this channel), so
1076
+ // the race-the-whole-turn-against-the-first-pending-signal machinery
1077
+ // below stays fully in place for both regardless: race the whole turn
1078
+ // against the *first* moment anything in it needs a human — and every
1079
+ // *subsequent* moment too (see raceAndRespond, called again from
1080
+ // POST .../approve|deny|answer below), not just the first — respond
1081
+ // early with everything the caller needs to decide it themselves, no
1082
+ // separate discovery/polling call required.
1083
+ const key = turnKey(agentName, rawSessionId)
1084
+ const state: SessionTurnState = { turnPromise: null, bufferedSignal: null, waiter: null, events: [{ type: 'session', sessionId: rawSessionId }] }
1085
+ sessionTurns.set(key, state)
93
1086
 
94
- const text = await sessions.withSession(storageSessionId, async (history) => {
95
- const result = await runAgent(entry.config, entry.createModelCall(), message, history)
96
- return { newMessages: result.newMessages, result: result.text }
1087
+ // Durable when LOOPENGINE_DEFAULT_WEBHOOK_URL/_SECRET are configured
1088
+ // (see this module's own defaultHttpWebhookNotifier) — otherwise falls
1089
+ // back to today's live WebchatApprover, tracked so a decide() call routes
1090
+ // back to this exact instance (see createTrackedApprover's own doc
1091
+ // comment). Either way, this agent's own config.httpNotifier (if it
1092
+ // covers 'approval') still wins outright over this — see
1093
+ // RunAgentOptions.approver's own doc comment.
1094
+ const approver =
1095
+ defaultHttpWebhookNotifier ??
1096
+ createTrackedApprover(rawSessionId, (approval) => {
1097
+ state.events.push({ type: 'approval:pending', ...approval })
1098
+ pushSignal(state, { type: 'approval', entry: approval })
1099
+ })
1100
+
1101
+ // Fresh modelCall per request — see core/agent-registry.ts. Not awaited
1102
+ // directly below — see raceAndRespond's own Promise.race for why.
1103
+ const turnPromise = sessions.withSession(storageSessionId, async (history) => {
1104
+ const result = await runAgent(entry.config, entry.createModelCall(), message, history, {
1105
+ tenant,
1106
+ sessionId: rawSessionId,
1107
+ channel: 'http',
1108
+ approver,
1109
+ // Durable when LOOPENGINE_DEFAULT_WEBHOOK_URL/_SECRET are
1110
+ // configured — see this module's own defaultHttpWebhookNotifier,
1111
+ // the same shared instance `approver` above already uses (one
1112
+ // instance, both concerns — see WebhookNotifier's own doc
1113
+ // comment). When unset, onQuestionPending below still applies
1114
+ // unchanged, same fallback-of-the-fallback relationship `approver`
1115
+ // above already has.
1116
+ questionHandler: defaultHttpWebhookNotifier,
1117
+ onEvent: (event) => state.events.push(event),
1118
+ onQuestionPending: (question) => {
1119
+ state.events.push({ type: 'question:pending', ...question })
1120
+ pushSignal(state, { type: 'question', entry: question })
1121
+ },
1122
+ })
1123
+ if (result.pending) {
1124
+ await createCheckpointFromPending(agentName, tenant, rawSessionId, result.pending)
1125
+ }
1126
+ // No SSE connection to write this to (see this route's own header
1127
+ // comment) — pushed straight onto state.events instead, same event
1128
+ // shape the streaming route's own 'done' SSE frame uses, so the final
1129
+ // `events` array this route's response body carries (see
1130
+ // raceAndRespond) always ends the same way regardless of which route
1131
+ // produced it.
1132
+ state.events.push({ type: 'done', text: result.text, ...(result.stopReason ? { stopReason: result.stopReason } : {}) })
1133
+ return { newMessages: result.newMessages, result: { text: result.text, stopReason: result.stopReason } }
97
1134
  })
1135
+ state.turnPromise = turnPromise
1136
+
1137
+ // The turn itself keeps running to completion regardless of how (or
1138
+ // whether) this specific request ever responds — sessions.withSession
1139
+ // still owns appending its result to the session log once it actually
1140
+ // resolves, same durability guarantee as any other call. Nothing else
1141
+ // is guaranteed to be awaiting turnPromise by the time it settles (a
1142
+ // caller might stop polling, or this response might win the race
1143
+ // below), so a failure needs its own catch here or it's a silent
1144
+ // unhandled rejection. Cleanup only removes *this* state if it's still
1145
+ // the current one for `key` — an unrelated new POST /messages reusing
1146
+ // the same (agent, sessionId) pair before this one's cleanup runs would
1147
+ // otherwise have its own fresh state clobbered here.
1148
+ turnPromise
1149
+ .catch((err) => {
1150
+ console.error(`[messages] background turn for '${agentName}' (session ${rawSessionId}) failed:`, err)
1151
+ })
1152
+ .finally(() => {
1153
+ if (sessionTurns.get(key) === state) sessionTurns.delete(key)
1154
+ })
98
1155
 
99
- res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ text, sessionId: rawSessionId }))
1156
+ await raceAndRespond(res, agentName, rawSessionId, state)
100
1157
  }
101
1158
 
102
1159
  async function handleMessagesStream(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
103
1160
  const parsed = await parseRequest(req, agentName)
104
1161
  if (!parsed.ok) {
1162
+ // Headers not sent yet — still a plain JSON error response, not SSE.
105
1163
  res.writeHead(parsed.status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: parsed.error }))
106
1164
  return
107
1165
  }
108
- const { entry, message, rawSessionId, storageSessionId } = parsed.value
1166
+ const { entry, message, rawSessionId, storageSessionId, tenant } = parsed.value
109
1167
 
110
1168
  res.writeHead(200, {
111
1169
  'content-type': 'text/event-stream',
112
1170
  'cache-control': 'no-cache',
113
1171
  connection: 'keep-alive',
114
1172
  })
115
- writeSseEvent(res, 'session', { sessionId: rawSessionId })
1173
+ // First event, always — same reason handleMessages echoes sessionId in
1174
+ // its JSON body: a caller that omitted sessionId needs some way to
1175
+ // learn what got generated in order to resume this conversation later.
1176
+ writeSseEvent(res, { type: 'session', sessionId: rawSessionId })
116
1177
 
117
- try {
118
- await sessions.withSession(storageSessionId, async (history) => {
119
- const result = await runAgent(entry.config, entry.createModelCall(), message, history, {
120
- onEvent: (event, detail) => writeSseEvent(res, event, detail),
121
- })
122
- writeSseEvent(res, 'done', { text: result.text })
123
- return { newMessages: result.newMessages, result: result.text }
1178
+ // Same shared per-(agent, sessionId) tracking the plain /messages route
1179
+ // uses (see sessionTurns' own doc comment) — registered here too so a
1180
+ // decide/answer call made *after* this SSE connection is gone (a
1181
+ // resumed session, with no live connection left to push onto) can still
1182
+ // learn what's next from its own response instead of falling back to
1183
+ // plain {ok: true} and leaving the playground to poll (see
1184
+ // playground.ts's own pollForCompletion, now dead code once this
1185
+ // shipped — nothing about a *live* connection needs this for itself,
1186
+ // writeSseEvent already pushes everything as it happens; this is purely
1187
+ // for whoever might decide something after the tab that started it is
1188
+ // long gone).
1189
+ const key = turnKey(agentName, rawSessionId)
1190
+ const state: SessionTurnState = { turnPromise: null, bufferedSignal: null, waiter: null, events: [{ type: 'session', sessionId: rawSessionId }] }
1191
+ sessionTurns.set(key, state)
1192
+
1193
+ // A fresh WebchatApprover per streamed turn, not the shared one the plain
1194
+ // /messages route passes — its onPending writes straight onto *this*
1195
+ // SSE connection, so the approve/deny popup shows up inline in the
1196
+ // conversation that's actually blocked on it, not on some separate page
1197
+ // an operator has to remember to check. Passed unconditionally, and
1198
+ // nothing can override it for this channel — AgentConfig.httpNotifier
1199
+ // only ever matches 'http', never 'http_stream' (see its own doc
1200
+ // comment), so this is always what actually decides an 'ask' here.
1201
+ const streamApprover = createTrackedApprover(rawSessionId, (approval) => {
1202
+ const event: LoopEvent = { type: 'approval:pending', ...approval }
1203
+ writeSseEvent(res, event)
1204
+ state.events.push(event)
1205
+ pushSignal(state, { type: 'approval', entry: approval })
1206
+ })
1207
+
1208
+ const turnPromise = sessions.withSession(storageSessionId, async (history) => {
1209
+ // onEvent already fires at every loop step (budget:check,
1210
+ // actauth:decision, toollane:result, ...) — streaming is just
1211
+ // forwarding those, not a separate code path through runAgent.
1212
+ // onQuestionPending is separate (see its own doc comment for why):
1213
+ // pushes straight onto this same SSE connection, same as
1214
+ // streamApprover's own onPending does for approvals.
1215
+ const result = await runAgent(entry.config, entry.createModelCall(), message, history, {
1216
+ tenant,
1217
+ sessionId: rawSessionId,
1218
+ channel: 'http_stream',
1219
+ approver: streamApprover,
1220
+ onEvent: (event) => {
1221
+ writeSseEvent(res, event)
1222
+ state.events.push(event)
1223
+ },
1224
+ onQuestionPending: (question) => {
1225
+ const event: LoopEvent = { type: 'question:pending', ...question }
1226
+ writeSseEvent(res, event)
1227
+ state.events.push(event)
1228
+ pushSignal(state, { type: 'question', entry: question })
1229
+ },
124
1230
  })
1231
+ if (result.pending) {
1232
+ await createCheckpointFromPending(agentName, tenant, rawSessionId, result.pending)
1233
+ }
1234
+ const doneEvent: LoopEvent = { type: 'done', text: result.text, ...(result.stopReason ? { stopReason: result.stopReason } : {}) }
1235
+ writeSseEvent(res, doneEvent)
1236
+ state.events.push(doneEvent)
1237
+ return { newMessages: result.newMessages, result: { text: result.text, stopReason: result.stopReason } }
1238
+ })
1239
+ state.turnPromise = turnPromise
1240
+
1241
+ try {
1242
+ await turnPromise
125
1243
  } catch (err) {
126
- writeSseEvent(res, 'error', { error: String(err) })
1244
+ // Headers are already sent by this point, so an error becomes an SSE
1245
+ // event, not an HTTP status code.
1246
+ const errorEvent: LoopEvent = { type: 'error', error: String(err) }
1247
+ writeSseEvent(res, errorEvent)
1248
+ state.events.push(errorEvent)
127
1249
  } finally {
1250
+ if (sessionTurns.get(key) === state) sessionTurns.delete(key)
128
1251
  res.end()
129
1252
  }
130
1253
  }
131
1254
 
1255
+ // Gates every route this server has — the whole admin surface (approve/
1256
+ // deny tool calls, read conversation history, edit permission rules,
1257
+ // register gateway tools) is otherwise open to anyone who can reach the
1258
+ // port (see server.listen below: it binds every interface, not just
1259
+ // localhost). HTTP Basic Auth specifically, not a bearer token, because
1260
+ // it's the one scheme a plain browser navigation (GET /playground, no JS
1261
+ // involved yet) can satisfy on its own — the browser's native login
1262
+ // prompt handles it, then resends the same credentials automatically on
1263
+ // every later request to this origin, admin UI's own fetch() calls
1264
+ // included. curl covers the same ground with `-u user:pass`.
1265
+ //
1266
+ // Off entirely (today's behavior, unchanged) when LOOPENGINE_ADMIN_AUTH
1267
+ // isn't set — this file is meant to run locally with zero setup by
1268
+ // default. A real deployment opts in by setting it; either way, a
1269
+ // startup warning makes "I forgot to set this" loud instead of silent.
1270
+ const adminAuth = process.env.LOOPENGINE_ADMIN_AUTH
1271
+ if (!adminAuth) {
1272
+ console.warn(
1273
+ '[loopengine] LOOPENGINE_ADMIN_AUTH is not set — every route on this server (including tool-call approvals, conversation history, and permission rules) is open to anyone who can reach it. Set LOOPENGINE_ADMIN_AUTH="user:pass" to require HTTP Basic Auth.',
1274
+ )
1275
+ }
1276
+
1277
+ // Compares the whole "user:pass" string as one shared secret, not
1278
+ // username and password separately — simpler, and just as sound for a
1279
+ // single shared credential with no per-user distinction to make.
1280
+ // timingSafeEqual requires equal-length buffers, so length is checked
1281
+ // first; a length mismatch isn't sensitive information worth spending a
1282
+ // constant-time comparison to protect.
1283
+ function isAuthorized(req: IncomingMessage): boolean {
1284
+ if (!adminAuth) return true
1285
+ const header = req.headers.authorization
1286
+ if (!header || !header.startsWith('Basic ')) return false
1287
+ const provided = Buffer.from(header.slice('Basic '.length), 'base64')
1288
+ const expected = Buffer.from(adminAuth)
1289
+ return provided.length === expected.length && timingSafeEqual(provided, expected)
1290
+ }
1291
+
1292
+ // Same tool-resolution shape describeAgent above already uses
1293
+ // (localTools/agentAsTools/gatewayTools) — reused here rather than
1294
+ // re-derived, so a resolved approval always executes the exact tool
1295
+ // implementation the paused turn itself would have called, not a second
1296
+ // guess at it. Deliberately doesn't include systemTools/askUserTool: a
1297
+ // DurableApprover's own pendingId only ever names a real, gated business
1298
+ // tool call (see run-agent.ts's own pendingCalls — system tools bypass
1299
+ // the gate entirely and can never end up 'pending').
1300
+ async function resolveAgentTool(config: AgentConfig, toolName: string): Promise<ToolDefinition | undefined> {
1301
+ const localTools = config.tools ?? (await loadDefaultTools(config))
1302
+ const agentAsTools = await loadSubagentAsTools(config)
1303
+ const gatewayTools = await loadGatewayToolsFromDir(agentDir(config.name))
1304
+ return [...localTools, ...agentAsTools, ...gatewayTools].find((t) => t.name === toolName)
1305
+ }
1306
+
1307
+ /** Deliberately not full JSON Schema validation — this project has no
1308
+ * ajv dependency, and one field-presence check is enough to stop an
1309
+ * approver's edited args from being obviously wrong (missing a field the
1310
+ * tool genuinely needs) without adding one just for this. See
1311
+ * HUMAN_IN_THE_LOOP.md's own "Approve-with-edit" section. */
1312
+ function validateEditedArgs(args: unknown, schema: Record<string, unknown>): string | undefined {
1313
+ if (typeof args !== 'object' || args === null || Array.isArray(args)) return 'must be an object'
1314
+ const required = Array.isArray(schema.required) ? schema.required : []
1315
+ for (const field of required) {
1316
+ if (typeof field === 'string' && !(field in args)) return `missing required field '${field}'`
1317
+ }
1318
+ return undefined
1319
+ }
1320
+
1321
+ // Called from both handleMessages and handleMessagesStream right after a
1322
+ // runAgent() call returns a non-empty `pending` — turns it into the
1323
+ // durable TurnCheckpoint a later resolve call looks up by pendingId.
1324
+ // Handles both kinds uniformly (a mixed batch lands both an 'approval'
1325
+ // and a 'question' item in the same checkpoint's outstanding — see
1326
+ // PendingItem's own doc comment for why that has to be true). One real
1327
+ // ordering caveat, not solved here: a DurableApprover's/
1328
+ // DurableQuestionHandler's own notification (e.g. a webhook) fires from
1329
+ // inside runAgent() itself, before it returns — an improbably fast
1330
+ // responder could in principle hit /pending-approvals/ or
1331
+ // /pending-questions/ before this has run and get an 'alreadyResolved'
1332
+ // false negative. Not worth restructuring around for a race that needs a
1333
+ // human to click a link in under the time it takes this call to return.
1334
+ async function createCheckpointFromPending(
1335
+ agent: string,
1336
+ tenant: string,
1337
+ sessionId: string,
1338
+ pending: NonNullable<RunAgentResult['pending']>,
1339
+ ): Promise<void> {
1340
+ const outstanding: TurnCheckpoint['outstanding'] = {}
1341
+ for (const item of pending.outstanding) {
1342
+ outstanding[item.pendingId] = { kind: item.kind, toolUseId: item.toolUseId, tool: item.tool, args: item.args, reason: item.reason }
1343
+ }
1344
+ await checkpoints.create({ sessionId, agent, tenant, resultsSoFar: pending.resultsSoFar, outstanding })
1345
+ }
1346
+
1347
+ /** Resolves one outstanding item on a durable checkpoint (see
1348
+ * HUMAN_IN_THE_LOOP.md) — approve (optionally with editedArgs) or deny —
1349
+ * and, once nothing's left outstanding, resumes the turn via
1350
+ * resumeAgent() (see respondAfterResolution below, shared with
1351
+ * handlePendingQuestionAnswer's own tail). Has no live sessionTurns entry
1352
+ * to fall back on the way /approvals/:id/approve|deny does: the whole
1353
+ * point of durable is that the process (or even the request) that
1354
+ * started this turn may be long gone by the time this fires, so the
1355
+ * response here is authoritative on its own, not a fallback for some
1356
+ * other channel. */
1357
+ async function handlePendingApprovalResolve(req: IncomingMessage, res: ServerResponse, pendingId: string): Promise<void> {
1358
+ const body = await readJsonBody(req)
1359
+ const decision = body.decision === 'approve' || body.decision === 'deny' ? body.decision : undefined
1360
+ if (!decision) {
1361
+ res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: "decision must be 'approve' or 'deny'" }))
1362
+ return
1363
+ }
1364
+ const editedArgs = body.editedArgs as Record<string, unknown> | undefined
1365
+
1366
+ type ResolveOutcome =
1367
+ | { kind: 'unknown' }
1368
+ | { kind: 'wrong-kind' }
1369
+ | { kind: 'validation-error'; error: string }
1370
+ | { kind: 'resolved'; checkpoint: TurnCheckpoint }
1371
+
1372
+ const outcome = await checkpoints.withCheckpoint(pendingId, async (checkpoint): Promise<{ checkpoint: TurnCheckpoint | undefined; result: ResolveOutcome }> => {
1373
+ if (!checkpoint) return { checkpoint: undefined, result: { kind: 'unknown' } }
1374
+ const item = checkpoint.outstanding[pendingId]
1375
+ // undefined kind means 'approval' — see OutstandingItem.kind's own
1376
+ // doc comment for why this stays optional/back-compat rather than
1377
+ // required.
1378
+ if (item.kind === 'question') return { checkpoint, result: { kind: 'wrong-kind' } }
1379
+
1380
+ let resultBlock: ModelContentBlock
1381
+ if (decision === 'deny') {
1382
+ resultBlock = { type: 'tool_result', tool_use_id: item.toolUseId, content: `denied: ${item.reason}`, is_error: true }
1383
+ } else {
1384
+ const entry = getEntry(checkpoint.agent)
1385
+ const tool = entry && (await resolveAgentTool(entry.config, item.tool))
1386
+ if (!entry) {
1387
+ resultBlock = { type: 'tool_result', tool_use_id: item.toolUseId, content: `error: agent '${checkpoint.agent}' no longer registered`, is_error: true }
1388
+ } else if (!tool) {
1389
+ resultBlock = { type: 'tool_result', tool_use_id: item.toolUseId, content: `error: tool '${item.tool}' no longer exists`, is_error: true }
1390
+ } else {
1391
+ const validationError = editedArgs ? validateEditedArgs(editedArgs, tool.input_schema) : undefined
1392
+ if (validationError) {
1393
+ // Leave the checkpoint completely untouched — persisting the
1394
+ // *same* object back is a harmless no-op write, not a real
1395
+ // update. This pendingId stays open so the approver can retry
1396
+ // with corrected args, instead of a typo burning the one
1397
+ // chance to resolve this call.
1398
+ return { checkpoint, result: { kind: 'validation-error', error: validationError } }
1399
+ }
1400
+ try {
1401
+ const output = await tool.execute(editedArgs ?? item.args)
1402
+ resultBlock = { type: 'tool_result', tool_use_id: item.toolUseId, content: JSON.stringify(output), is_error: false }
1403
+ } catch (err) {
1404
+ resultBlock = { type: 'tool_result', tool_use_id: item.toolUseId, content: `ERROR: ${err}`, is_error: true }
1405
+ }
1406
+ }
1407
+ }
1408
+
1409
+ const { [pendingId]: _resolvedItem, ...remainingOutstanding } = checkpoint.outstanding
1410
+ const resultsSoFar = [...checkpoint.resultsSoFar, resultBlock]
1411
+ let outstanding = remainingOutstanding
1412
+ // A denial closes the *whole* checkpoint immediately, same as
1413
+ // run-agent.ts's own synchronous denial path — any other still-
1414
+ // outstanding item in this batch gets a synthesized skip, it never
1415
+ // gets left dangling for someone to resolve later.
1416
+ if (decision === 'deny') {
1417
+ for (const otherItem of Object.values(remainingOutstanding)) {
1418
+ resultsSoFar.push({
1419
+ type: 'tool_result',
1420
+ tool_use_id: otherItem.toolUseId,
1421
+ content: `skipped: a sibling tool call in this turn (${item.tool}) was denied`,
1422
+ is_error: true,
1423
+ })
1424
+ }
1425
+ outstanding = {}
1426
+ }
1427
+
1428
+ const updated: TurnCheckpoint = { ...checkpoint, resultsSoFar, outstanding, closed: Object.keys(outstanding).length === 0 }
1429
+ return { checkpoint: updated, result: { kind: 'resolved', checkpoint: updated } }
1430
+ })
1431
+
1432
+ if (outcome.kind === 'unknown') {
1433
+ // Unknown pendingId, or its checkpoint was already closed — a
1434
+ // sibling denial got there first, or this exact pendingId was
1435
+ // already resolved. Graceful no-op, not an error (see
1436
+ // HUMAN_IN_THE_LOOP.md's own worked example for why this has to be).
1437
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ alreadyResolved: true }))
1438
+ return
1439
+ }
1440
+
1441
+ if (outcome.kind === 'wrong-kind') {
1442
+ res.writeHead(400, { 'content-type': 'application/json' }).end(
1443
+ JSON.stringify({ error: `pendingId '${pendingId}' is a pending question, not a pending approval — use POST /pending-questions/${pendingId}/answer` }),
1444
+ )
1445
+ return
1446
+ }
1447
+
1448
+ if (outcome.kind === 'validation-error') {
1449
+ res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `editedArgs invalid — ${outcome.error}` }))
1450
+ return
1451
+ }
1452
+
1453
+ await respondAfterResolution(res, outcome.checkpoint)
1454
+ }
1455
+
1456
+ /** Resolves one outstanding durable question (see HUMAN_IN_THE_LOOP.md's
1457
+ * "Durable questions" section) — the question-side sibling of
1458
+ * handlePendingApprovalResolve above. Simpler than an approval's own
1459
+ * resolution: the human's free-text answer becomes the completing
1460
+ * tool_result content directly, no tool.execute() involved (a question
1461
+ * has nothing to run), and no deny/editedArgs concept — a question is
1462
+ * either answered or it isn't. Shares the same "outstanding empty →
1463
+ * resume, else report remaining count" tail via respondAfterResolution. */
1464
+ async function handlePendingQuestionAnswer(req: IncomingMessage, res: ServerResponse, pendingId: string): Promise<void> {
1465
+ const body = await readJsonBody(req)
1466
+ if (typeof body.answer !== 'string') {
1467
+ res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: "'answer' must be a string" }))
1468
+ return
1469
+ }
1470
+ const answer = body.answer
1471
+
1472
+ type ResolveOutcome = { kind: 'unknown' } | { kind: 'wrong-kind' } | { kind: 'resolved'; checkpoint: TurnCheckpoint }
1473
+
1474
+ const outcome = await checkpoints.withCheckpoint(pendingId, async (checkpoint): Promise<{ checkpoint: TurnCheckpoint | undefined; result: ResolveOutcome }> => {
1475
+ if (!checkpoint) return { checkpoint: undefined, result: { kind: 'unknown' } }
1476
+ const item = checkpoint.outstanding[pendingId]
1477
+ // undefined/'approval' kind hitting the question route is the mirror
1478
+ // mismatch of handlePendingApprovalResolve's own guard above.
1479
+ if (item.kind !== 'question') return { checkpoint, result: { kind: 'wrong-kind' } }
1480
+
1481
+ const resultBlock: ModelContentBlock = { type: 'tool_result', tool_use_id: item.toolUseId, content: answer, is_error: false }
1482
+ const { [pendingId]: _resolvedItem, ...remainingOutstanding } = checkpoint.outstanding
1483
+ const resultsSoFar = [...checkpoint.resultsSoFar, resultBlock]
1484
+ const updated: TurnCheckpoint = { ...checkpoint, resultsSoFar, outstanding: remainingOutstanding, closed: Object.keys(remainingOutstanding).length === 0 }
1485
+ return { checkpoint: updated, result: { kind: 'resolved', checkpoint: updated } }
1486
+ })
1487
+
1488
+ if (outcome.kind === 'unknown') {
1489
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ alreadyResolved: true }))
1490
+ return
1491
+ }
1492
+
1493
+ if (outcome.kind === 'wrong-kind') {
1494
+ res.writeHead(400, { 'content-type': 'application/json' }).end(
1495
+ JSON.stringify({ error: `pendingId '${pendingId}' is a pending approval, not a pending question — use POST /pending-approvals/${pendingId}/resolve` }),
1496
+ )
1497
+ return
1498
+ }
1499
+
1500
+ await respondAfterResolution(res, outcome.checkpoint)
1501
+ }
1502
+
1503
+ /** Shared tail for both durable resolve routes above, once a single
1504
+ * outstanding item has just been resolved: report how many items are
1505
+ * still outstanding, or — once none are — resume the turn for real via
1506
+ * resumeAgent(), through the same durable sessions.withSession append
1507
+ * path any other turn goes through. */
1508
+ async function respondAfterResolution(res: ServerResponse, finalCheckpoint: TurnCheckpoint): Promise<void> {
1509
+ if (Object.keys(finalCheckpoint.outstanding).length > 0) {
1510
+ res.writeHead(200, { 'content-type': 'application/json' }).end(
1511
+ JSON.stringify({ resolved: true, outstanding: Object.keys(finalCheckpoint.outstanding).length }),
1512
+ )
1513
+ return
1514
+ }
1515
+
1516
+ const entry = getEntry(finalCheckpoint.agent)
1517
+ if (!entry) {
1518
+ res.writeHead(500, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `agent '${finalCheckpoint.agent}' no longer registered` }))
1519
+ return
1520
+ }
1521
+ const environment = process.env.LOOPENGINE_ENV ?? 'production'
1522
+ const storageSessionId = `${finalCheckpoint.tenant}:${environment}:${finalCheckpoint.agent}:${finalCheckpoint.sessionId}`
1523
+
1524
+ const responseBody = await sessions.withSession(storageSessionId, async (history) => {
1525
+ // Same channel/approver/questionHandler fallback the original
1526
+ // (now-resolved) call used — a chained second pending item should
1527
+ // behave identically to the first, not fall through further just
1528
+ // because it's mid-resume. No live connection to wire an onPending
1529
+ // push onto here (unlike handleMessages' own WebchatApprover) — the
1530
+ // approver is still tracked, so GET /agents/:name/approvals and
1531
+ // POST /approvals/:id/approve|deny can find and resolve it, and its
1532
+ // own 5-minute timeout still applies rather than this hanging
1533
+ // forever. A resumed turn's own new system_ask_user call, likewise,
1534
+ // is durable when defaultHttpWebhookNotifier is configured — and
1535
+ // when it isn't, onQuestionPending still has to be *something*
1536
+ // (not omitted): createAskUserTool's own live/console-fallback branch
1537
+ // keys off onQuestionPending's mere presence, not usefulness, and
1538
+ // omitting it here (as an earlier version of this code did) meant a
1539
+ // resumed turn's fresh question blocked the *server process's own
1540
+ // stdin* — unrecoverable via any HTTP endpoint, not just live-but-
1541
+ // unwatched. A no-op is enough to route it into the shared
1542
+ // WebchatQuestionHandler registry instead — same "still tracked, still
1543
+ // answerable via the REST endpoints, still times out on its own"
1544
+ // safety net createTrackedApprover(sessionId) (no onPending) already
1545
+ // gives a resumed approval just above.
1546
+ const result = await resumeAgent(entry.config, entry.createModelCall(), history, finalCheckpoint.resultsSoFar, {
1547
+ tenant: finalCheckpoint.tenant,
1548
+ sessionId: finalCheckpoint.sessionId,
1549
+ channel: 'http',
1550
+ approver: defaultHttpWebhookNotifier ?? createTrackedApprover(finalCheckpoint.sessionId),
1551
+ questionHandler: defaultHttpWebhookNotifier,
1552
+ onQuestionPending: () => {},
1553
+ })
1554
+ // The resumed turn can itself hit another durably-pending call/
1555
+ // question — same wiring as the fresh-turn call sites above, or its
1556
+ // own new pendingId(s) would have nowhere indexed to be resolved
1557
+ // against.
1558
+ if (result.pending) {
1559
+ await createCheckpointFromPending(finalCheckpoint.agent, finalCheckpoint.tenant, finalCheckpoint.sessionId, result.pending)
1560
+ }
1561
+ return { newMessages: result.newMessages, result: { text: result.text, stopReason: result.stopReason } }
1562
+ })
1563
+
1564
+ res.writeHead(200, { 'content-type': 'application/json' }).end(
1565
+ JSON.stringify({ text: responseBody.text, sessionId: finalCheckpoint.sessionId, ...(responseBody.stopReason ? { stopReason: responseBody.stopReason } : {}) }),
1566
+ )
1567
+ }
1568
+
132
1569
  const server = createServer(async (req, res) => {
1570
+ if (!isAuthorized(req)) {
1571
+ res
1572
+ .writeHead(401, { 'content-type': 'application/json', 'www-authenticate': 'Basic realm="loopengine"' })
1573
+ .end(JSON.stringify({ error: 'authorization required' }))
1574
+ return
1575
+ }
1576
+
1577
+ // One catch-all around both routes. node:http does not catch rejections
1578
+ // from an async request listener itself — an uncaught one here doesn't
1579
+ // just fail the request, it crashes the whole process (confirmed: an
1580
+ // unhandled rejection anywhere in a route took the entire server down
1581
+ // before this existed). handleMessagesStream's own try/catch below
1582
+ // still handles errors *after* SSE headers are sent (those need an
1583
+ // in-band `error` event, not a fresh status code) — this is the
1584
+ // backstop for everything before that point, for both routes.
133
1585
  try {
134
- const streamMatch = req.method === 'POST' && req.url?.match(/^\/agents\/([^/]+)\/messages\/stream$/)
1586
+ // Query strings (the browser pages below link to each other with
1587
+ // ?agent=<name> to deep-link a preselected agent) would otherwise
1588
+ // break every exact-match/regex `$`-anchored route below, which only
1589
+ // ever expected a bare path — stripped once here rather than in each
1590
+ // route individually.
1591
+ const pathname = (req.url ?? '/').split('?')[0]
1592
+
1593
+ const streamMatch = req.method === 'POST' && pathname.match(/^\/agents\/([^/]+)\/messages\/stream$/)
135
1594
  if (streamMatch) {
136
1595
  await handleMessagesStream(req, res, decodeURIComponent(streamMatch[1]))
137
1596
  return
138
1597
  }
139
1598
 
140
- const match = req.method === 'POST' && req.url?.match(/^\/agents\/([^/]+)\/messages$/)
1599
+ const sessionGetMatch = req.method === 'GET' && pathname.match(/^\/agents\/([^/]+)\/sessions\/([^/]+)$/)
1600
+ if (sessionGetMatch) {
1601
+ await handleSessionGet(req, res, decodeURIComponent(sessionGetMatch[1]), decodeURIComponent(sessionGetMatch[2]))
1602
+ return
1603
+ }
1604
+
1605
+ // Dev playground: a browser client on top of the two routes above,
1606
+ // rendering the same SSE events /messages/stream already emits — see
1607
+ // web/playground.ts. GET, not POST, and an exact path match
1608
+ // (neither has a :name segment), so no risk of colliding with the
1609
+ // regexes above (already partitioned by method).
1610
+ if (req.method === 'GET' && pathname === '/playground') {
1611
+ res.writeHead(200, { 'content-type': 'text/html' }).end(playgroundHtml)
1612
+ return
1613
+ }
1614
+
1615
+ // Pending 'ask' decisions — no browser page of its own: a streamed
1616
+ // chat turn (handleMessagesStream above) pushes its own pending
1617
+ // approvals straight onto that conversation's SSE connection instead,
1618
+ // so this is really just the plain-JSON escape hatch for the
1619
+ // non-streaming /messages route, or any other client that wants to
1620
+ // decide by hand. Scoped to one agent (exact) and, when given, one
1621
+ // session (best-effort — see web/web-approver.ts's sessionByApprover for
1622
+ // why that's not always knowable) — never a blanket list across every
1623
+ // agent/tenant this process serves, which would leak one
1624
+ // conversation's pending approvals to any caller asking about a
1625
+ // completely different one.
1626
+ const approvalsSessionMatch = req.method === 'GET' && pathname.match(/^\/agents\/([^/]+)\/sessions\/([^/]+)\/approvals$/)
1627
+ if (approvalsSessionMatch) {
1628
+ const agent = decodeURIComponent(approvalsSessionMatch[1])
1629
+ const sessionId = decodeURIComponent(approvalsSessionMatch[2])
1630
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ approvals: listApprovals({ agent, sessionId }) }))
1631
+ return
1632
+ }
1633
+ const approvalsAgentMatch = req.method === 'GET' && pathname.match(/^\/agents\/([^/]+)\/approvals$/)
1634
+ if (approvalsAgentMatch) {
1635
+ const agent = decodeURIComponent(approvalsAgentMatch[1])
1636
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ approvals: listApprovals({ agent }) }))
1637
+ return
1638
+ }
1639
+ const approvalDecisionMatch = req.method === 'POST' && pathname.match(/^\/approvals\/([^/]+)\/(approve|deny)$/)
1640
+ if (approvalDecisionMatch) {
1641
+ const id = decodeURIComponent(approvalDecisionMatch[1])
1642
+ const approved = approvalDecisionMatch[2] === 'approve'
1643
+ // Looked up *before* deciding — decideApproval below removes it (see
1644
+ // web/web-approver.ts's own onSettled), so this is the last chance to
1645
+ // learn which agent/session it belonged to. No await in between, so
1646
+ // nothing else can race in and decide it out from under this lookup.
1647
+ const found = findApproval(id)
1648
+ if (!found) {
1649
+ res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `No pending approval '${id}' (already decided, timed out, or never existed).` }))
1650
+ return
1651
+ }
1652
+ decideApproval(id, approved)
1653
+ // A session-turn state only exists for a turn started via the plain
1654
+ // POST /messages route (see its own doc comment) — one raised via
1655
+ // the streaming route, or whose owning turn already finished and
1656
+ // cleaned itself up, falls back to the old plain {ok: true}: that
1657
+ // caller already has its own way of finding out what happens next
1658
+ // (its still-open SSE connection, or the fact the turn's already
1659
+ // over). Known, accepted gap: two overlapping POST /messages calls
1660
+ // for the very same (agent, sessionId) before the first resolves
1661
+ // would leave this decide racing the *second* call's state instead
1662
+ // of the first's — sessions.withSession's own per-session lock
1663
+ // still serializes their actual execution either way, so nothing
1664
+ // is lost or corrupted, just possibly reported against the wrong
1665
+ // one of the two. Sending a second message before the first
1666
+ // finishes isn't a flow this API is meant to support regardless.
1667
+ const state = sessionTurns.get(turnKey(found.approval.scope.agent, found.sessionId))
1668
+ if (state) {
1669
+ // A streamed turn registers this same state (see
1670
+ // handleMessagesStream) but never drains it itself — its own live
1671
+ // SSE connection already delivers everything as it happens, so
1672
+ // pushSignal's buffer (see its own doc comment) just sits there
1673
+ // holding *this exact* now-decided approval, unconsumed, the
1674
+ // whole time. Left alone, the race below would immediately "win"
1675
+ // on that stale entry instead of actually waiting for whatever
1676
+ // comes next — confirmed live: approving step_one echoed step_one
1677
+ // straight back as the "next" pending item. Only one thing can
1678
+ // ever be genuinely pending at a time per session (run-agent.ts's
1679
+ // own gate loop blocks on one decision before evaluating the
1680
+ // next), so any buffered signal still sitting here at this exact
1681
+ // moment can only be the one just decided — safe to discard
1682
+ // unconditionally, not just when it happens to match this id.
1683
+ state.bufferedSignal = null
1684
+ await raceAndRespond(res, found.approval.scope.agent, found.sessionId, state)
1685
+ return
1686
+ }
1687
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true }))
1688
+ return
1689
+ }
1690
+
1691
+ // A DurableApprover's own pending decision — see HUMAN_IN_THE_LOOP.md.
1692
+ // Unlike /approvals/:id/approve|deny above, this doesn't assume a live
1693
+ // sessionTurns entry exists at all (the whole point of durable is that
1694
+ // the process that started the turn may be long gone) — resolving one
1695
+ // just updates the checkpoint and, once nothing's left outstanding,
1696
+ // resumes the turn fresh via resumeAgent + sessions.withSession, the
1697
+ // same durable append path any other turn goes through.
1698
+ const pendingApprovalResolveMatch = req.method === 'POST' && pathname.match(/^\/pending-approvals\/([^/]+)\/resolve$/)
1699
+ if (pendingApprovalResolveMatch) {
1700
+ await handlePendingApprovalResolve(req, res, decodeURIComponent(pendingApprovalResolveMatch[1]))
1701
+ return
1702
+ }
1703
+
1704
+ // A DurableQuestionHandler's own pending question — question-side
1705
+ // sibling of /pending-approvals/:id/resolve just above, same
1706
+ // reasoning (see HUMAN_IN_THE_LOOP.md's "Durable questions" section):
1707
+ // no live sessionTurns entry assumed, resolving one just updates the
1708
+ // checkpoint and, once nothing's left outstanding, resumes the turn
1709
+ // fresh via resumeAgent + sessions.withSession.
1710
+ const pendingQuestionAnswerMatch = req.method === 'POST' && pathname.match(/^\/pending-questions\/([^/]+)\/answer$/)
1711
+ if (pendingQuestionAnswerMatch) {
1712
+ await handlePendingQuestionAnswer(req, res, decodeURIComponent(pendingQuestionAnswerMatch[1]))
1713
+ return
1714
+ }
1715
+
1716
+ // Pending ask_user questions (see system-tools/ask_user.ts) — same
1717
+ // shape/reasoning as /approvals just above: a streamed chat turn
1718
+ // pushes its own 'question:pending' SSE event straight onto that
1719
+ // conversation (see playground.ts's inline card for it), so this is
1720
+ // the plain-JSON escape hatch for anything else — the non-streaming
1721
+ // /messages route, a plain CLI run's fallback console prompt aside,
1722
+ // or a client that wants to answer by hand. Scoped to one agent and,
1723
+ // when given, one exact session — every question always knows both
1724
+ // (see PendingQuestion's own doc comment), so unlike approvals this
1725
+ // scoping is never just best-effort.
1726
+ const questionsSessionMatch = req.method === 'GET' && pathname.match(/^\/agents\/([^/]+)\/sessions\/([^/]+)\/questions$/)
1727
+ if (questionsSessionMatch) {
1728
+ const agent = decodeURIComponent(questionsSessionMatch[1])
1729
+ const sessionId = decodeURIComponent(questionsSessionMatch[2])
1730
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ questions: listQuestions({ agent, sessionId }) }))
1731
+ return
1732
+ }
1733
+ const questionsAgentMatch = req.method === 'GET' && pathname.match(/^\/agents\/([^/]+)\/questions$/)
1734
+ if (questionsAgentMatch) {
1735
+ const agent = decodeURIComponent(questionsAgentMatch[1])
1736
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ questions: listQuestions({ agent }) }))
1737
+ return
1738
+ }
1739
+ const questionAnswerMatch = req.method === 'POST' && pathname.match(/^\/questions\/([^/]+)\/answer$/)
1740
+ if (questionAnswerMatch) {
1741
+ const id = decodeURIComponent(questionAnswerMatch[1])
1742
+ const body = await readJsonBody(req)
1743
+ const answer = String(body.answer ?? '')
1744
+ // Same "look up before deciding" reasoning as the approval route
1745
+ // just above.
1746
+ const found = findQuestion(id)
1747
+ if (!found) {
1748
+ res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `No pending question '${id}' (already answered, timed out, or never existed).` }))
1749
+ return
1750
+ }
1751
+ answerQuestion(id, answer)
1752
+ // See the approval route's own doc comment for why a missing state
1753
+ // (or a question with no sessionId at all — see PendingQuestion's
1754
+ // own doc comment for when that happens) falls back to plain
1755
+ // {ok: true} instead.
1756
+ const state = found.sessionId ? sessionTurns.get(turnKey(found.agent, found.sessionId)) : undefined
1757
+ if (state && found.sessionId) {
1758
+ // See the approval route's own doc comment on this exact line —
1759
+ // same staleness hazard, same fix.
1760
+ state.bufferedSignal = null
1761
+ await raceAndRespond(res, found.agent, found.sessionId, state)
1762
+ return
1763
+ }
1764
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true }))
1765
+ return
1766
+ }
1767
+
1768
+ if (req.method === 'GET' && pathname === '/agents') {
1769
+ if (prefersHtml(req)) {
1770
+ res.writeHead(200, { 'content-type': 'text/html' }).end(agentsListPageHtml)
1771
+ return
1772
+ }
1773
+ res.writeHead(200, { 'content-type': 'application/json' }).end(
1774
+ JSON.stringify({ agents: listAgents().map((name) => ({ name, systemPrompt: getEntry(name)!.config.systemPrompt })) }),
1775
+ )
1776
+ return
1777
+ }
1778
+ if (req.method === 'POST' && pathname === '/agents') {
1779
+ await handleCreateAgent(req, res)
1780
+ return
1781
+ }
1782
+ const editAgentMatch = req.method === 'PUT' && pathname.match(/^\/agents\/([^/]+)$/)
1783
+ if (editAgentMatch) {
1784
+ await handleEditAgent(req, res, decodeURIComponent(editAgentMatch[1]))
1785
+ return
1786
+ }
1787
+
1788
+ // Browser page: the account-wide counterpart to the per-agent
1789
+ // Overview/Skills/Tools/ActAuth tabs below — a left sidebar of
1790
+ // Models/Gateways (plus a plain link out to the per-agent Agents
1791
+ // page), neither of which is a property of any one agent (see
1792
+ // global-config.ts's own header comment for why those live in a
1793
+ // separate module and page from web/agents-config-page.ts).
1794
+ if (req.method === 'GET' && pathname === '/config') {
1795
+ res.writeHead(200, { 'content-type': 'text/html' }).end(globalConfigPageHtml)
1796
+ return
1797
+ }
1798
+ if (req.method === 'GET' && pathname === '/config/models') {
1799
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(describeModelProviders(listAgents(), (name) => getEntry(name)?.config)))
1800
+ return
1801
+ }
1802
+ if (req.method === 'GET' && pathname === '/config/gateways') {
1803
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(await describeGateways()))
1804
+ return
1805
+ }
1806
+ if (req.method === 'POST' && pathname === '/config/gateways/composio/disconnect') {
1807
+ await handleComposioDisconnect(res)
1808
+ return
1809
+ }
1810
+
1811
+ // Browser page: lists every registered agent and renders its full
1812
+ // config (tools, permissions, sessionIdFor, ...) via the JSON route
1813
+ // below — see web/agents-config-page.ts. A fixed two-segment
1814
+ // path, so it can't collide with the three-segment
1815
+ // /agents/:name/config regex right below it.
1816
+ if (req.method === 'GET' && pathname === '/agents/config') {
1817
+ res.writeHead(200, { 'content-type': 'text/html' }).end(agentsConfigPageHtml)
1818
+ return
1819
+ }
1820
+
1821
+ const configMatch = req.method === 'GET' && pathname.match(/^\/agents\/([^/]+)\/config$/)
1822
+ if (configMatch) {
1823
+ const agentName = decodeURIComponent(configMatch[1])
1824
+ const entry = getEntry(agentName)
1825
+ if (!entry) {
1826
+ res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
1827
+ return
1828
+ }
1829
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(await describeAgent(entry)))
1830
+ return
1831
+ }
1832
+
1833
+ // Backs the add-a-source picker (see handleComposioConnections's own
1834
+ // doc comment for why these aren't under /agents/:name/). Checked
1835
+ // before the /agents/... routes below only because they're declared
1836
+ // first here — there's no actual path overlap to worry about, these
1837
+ // don't start with /agents at all.
1838
+ if (req.method === 'GET' && pathname === '/composio/connections') {
1839
+ await handleComposioConnections(res)
1840
+ return
1841
+ }
1842
+ if (req.method === 'GET' && pathname === '/composio/tools') {
1843
+ const toolkit = new URL(req.url ?? '/', 'http://localhost').searchParams.get('toolkit') ?? undefined
1844
+ await handleComposioTools(res, toolkit)
1845
+ return
1846
+ }
1847
+
1848
+ // No standalone admin page for these — gateway-tools.ts's registry is
1849
+ // managed from a "Gateway tools" tab inside /agents/config now (see
1850
+ // web/agents-config-page.ts), not its own page. These JSON
1851
+ // routes are what that tab's script calls.
1852
+ // Three segments after gateway-tools/ (sourceName/slug) — checked
1853
+ // before the two-segment (sourceName only) route right below; the
1854
+ // trailing `$` anchor on each means they never actually overlap
1855
+ // regardless of order, but the more specific one reads better first.
1856
+ const gatewayToolSlugDeleteMatch = req.method === 'DELETE' && pathname.match(/^\/agents\/([^/]+)\/gateway-tools\/([^/]+)\/([^/]+)$/)
1857
+ if (gatewayToolSlugDeleteMatch) {
1858
+ await handleGatewayToolSlugDelete(
1859
+ res,
1860
+ decodeURIComponent(gatewayToolSlugDeleteMatch[1]),
1861
+ decodeURIComponent(gatewayToolSlugDeleteMatch[2]),
1862
+ decodeURIComponent(gatewayToolSlugDeleteMatch[3]),
1863
+ )
1864
+ return
1865
+ }
1866
+
1867
+ const gatewayToolsDeleteMatch = req.method === 'DELETE' && pathname.match(/^\/agents\/([^/]+)\/gateway-tools\/([^/]+)$/)
1868
+ if (gatewayToolsDeleteMatch) {
1869
+ handleToolSourcesDelete(res, decodeURIComponent(gatewayToolsDeleteMatch[1]), decodeURIComponent(gatewayToolsDeleteMatch[2]))
1870
+ return
1871
+ }
1872
+
1873
+ const gatewayToolsMatch = pathname.match(/^\/agents\/([^/]+)\/gateway-tools$/)
1874
+ if (gatewayToolsMatch && req.method === 'GET') {
1875
+ // ?refresh=1 is the admin page's own "Refresh" button — bypasses
1876
+ // describeGatewayTools' own cache to actually re-check Composio
1877
+ // live (see its own doc comment for why a plain page load doesn't).
1878
+ const force = new URLSearchParams((req.url ?? '').split('?')[1] ?? '').get('refresh') === '1'
1879
+ await handleToolSourcesGet(res, decodeURIComponent(gatewayToolsMatch[1]), force)
1880
+ return
1881
+ }
1882
+ if (gatewayToolsMatch && req.method === 'POST') {
1883
+ await handleToolSourcesPost(req, res, decodeURIComponent(gatewayToolsMatch[1]))
1884
+ return
1885
+ }
1886
+
1887
+ const httpToolMatch = req.method === 'POST' && pathname.match(/^\/agents\/([^/]+)\/tools\/http$/)
1888
+ if (httpToolMatch) {
1889
+ await handleHttpToolPost(req, res, decodeURIComponent(httpToolMatch[1]))
1890
+ return
1891
+ }
1892
+
1893
+ // Backs the Skills tab's edit form — see skills-admin.ts for why
1894
+ // :skillId is restricted to a flat (non-nested) id.
1895
+ const skillMatch = pathname.match(/^\/agents\/([^/]+)\/skills\/([^/]+)$/)
1896
+ if (skillMatch && req.method === 'GET') {
1897
+ handleSkillGet(res, decodeURIComponent(skillMatch[1]), decodeURIComponent(skillMatch[2]))
1898
+ return
1899
+ }
1900
+ if (skillMatch && req.method === 'PUT') {
1901
+ await handleSkillPut(req, res, decodeURIComponent(skillMatch[1]), decodeURIComponent(skillMatch[2]))
1902
+ return
1903
+ }
1904
+ if (skillMatch && req.method === 'DELETE') {
1905
+ handleSkillDelete(res, decodeURIComponent(skillMatch[1]), decodeURIComponent(skillMatch[2]))
1906
+ return
1907
+ }
1908
+
1909
+ // Backs the Actauth tab's rule editor and default_decision control.
1910
+ // The default-decision route is checked before the :ruleName routes
1911
+ // right below since 'default-decision' would otherwise itself match
1912
+ // as a rule name.
1913
+ const actauthDefaultDecisionMatch = req.method === 'PUT' && pathname.match(/^\/agents\/([^/]+)\/actauth\/default-decision$/)
1914
+ if (actauthDefaultDecisionMatch) {
1915
+ await handleActauthDefaultDecisionPut(req, res, decodeURIComponent(actauthDefaultDecisionMatch[1]))
1916
+ return
1917
+ }
1918
+
1919
+ const actauthRuleMatch = pathname.match(/^\/agents\/([^/]+)\/actauth\/rules\/([^/]+)$/)
1920
+ if (actauthRuleMatch && req.method === 'PUT') {
1921
+ await handleActauthRulePut(req, res, decodeURIComponent(actauthRuleMatch[1]), decodeURIComponent(actauthRuleMatch[2]))
1922
+ return
1923
+ }
1924
+ if (actauthRuleMatch && req.method === 'DELETE') {
1925
+ handleActauthRuleDelete(res, decodeURIComponent(actauthRuleMatch[1]), decodeURIComponent(actauthRuleMatch[2]))
1926
+ return
1927
+ }
1928
+
1929
+ const actauthRulesMatch = pathname.match(/^\/agents\/([^/]+)\/actauth\/rules$/)
1930
+ if (actauthRulesMatch && req.method === 'POST') {
1931
+ await handleActauthRulePost(req, res, decodeURIComponent(actauthRulesMatch[1]))
1932
+ return
1933
+ }
1934
+
1935
+ const actauthMatch = req.method === 'GET' && pathname.match(/^\/agents\/([^/]+)\/actauth$/)
1936
+ if (actauthMatch) {
1937
+ handleActauthGet(res, decodeURIComponent(actauthMatch[1]))
1938
+ return
1939
+ }
1940
+
1941
+ const match = req.method === 'POST' && pathname.match(/^\/agents\/([^/]+)\/messages$/)
141
1942
  if (!match) {
142
1943
  res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'not found' }))
143
1944
  return