create-loopengine 0.1.8 → 0.1.11

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