create-loopengine 0.1.76 → 0.1.78

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-loopengine",
3
- "version": "0.1.76",
3
+ "version": "0.1.78",
4
4
  "description": "Scaffold a new loopengine agent project — a runnable HTTP server with one example agent, in your own repo.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -148,6 +148,7 @@ import {
148
148
  slackClickAllowed,
149
149
  agentApprovers,
150
150
  setAgentApprovers,
151
+ setAgentPrimaryNotifier,
151
152
  listAgentSlackMembers,
152
153
  replyPrivatelyInSlack,
153
154
  slackArgs,
@@ -189,6 +190,32 @@ import {
189
190
  AbilityNotInstalledError,
190
191
  searchPublicAbilities,
191
192
  fetchLatestAbilityVersion,
193
+ describeAbilitySources,
194
+ listPrivateAbilities,
195
+ removeAbilitySourceFromConsole,
196
+ saveAbilitySourceFromConsole,
197
+ abilityDetails,
198
+ WorkflowError,
199
+ createWorkflow,
200
+ forgetWorkflow,
201
+ getPlanJob,
202
+ listAgentWorkflows,
203
+ planWorkflow,
204
+ runWorkflowNow,
205
+ startPlanJob,
206
+ type WorkflowAgentInfo,
207
+ workflowPageHtml,
208
+ MAX_UPLOAD_BYTES,
209
+ installUploadedAbility,
210
+ installWorkspaceAbility,
211
+ type AbilityUpload,
212
+ WorkspaceAbilityError,
213
+ listWorkspaceAbilities,
214
+ removeWorkspaceAbility,
215
+ AbilitySourceError,
216
+ abilityOrigin,
217
+ readAbilitySources,
218
+ type AbilitySource,
192
219
  describeModelProviders,
193
220
  loopengineVersion,
194
221
  describeUpgrade,
@@ -196,6 +223,7 @@ import {
196
223
  UpgradeError,
197
224
  setModelProviderKey,
198
225
  clearModelProviderKey,
226
+ listProviderModels,
199
227
  ModelKeyError,
200
228
  describeTimeZone,
201
229
  setWorkspaceTimeZone,
@@ -319,6 +347,32 @@ function readRawBody(req: IncomingMessage): Promise<string> {
319
347
  })
320
348
  }
321
349
 
350
+ /** readJsonBody with a size cap, for uploads: a body past `maxBytes` is
351
+ * refused before it's all read. */
352
+ function readLimitedJsonBody(req: IncomingMessage, maxBytes: number): Promise<Record<string, unknown>> {
353
+ return new Promise((resolve, reject) => {
354
+ const chunks: Buffer[] = []
355
+ let size = 0
356
+ req.on('data', (chunk: Buffer) => {
357
+ size += chunk.length
358
+ if (size > maxBytes) {
359
+ reject(new WorkspaceAbilityError('The upload is larger than 25 MB'))
360
+ req.destroy()
361
+ return
362
+ }
363
+ chunks.push(chunk)
364
+ })
365
+ req.on('end', () => {
366
+ try {
367
+ resolve(chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {})
368
+ } catch (err) {
369
+ reject(err)
370
+ }
371
+ })
372
+ req.on('error', reject)
373
+ })
374
+ }
375
+
322
376
  function readJsonBody(req: IncomingMessage): Promise<Record<string, unknown>> {
323
377
  return new Promise((resolve, reject) => {
324
378
  let data = ''
@@ -866,7 +920,9 @@ function parseAgentTemplateOptions(body: Record<string, unknown>): { ok: true; v
866
920
  provider !== 'deepseek' &&
867
921
  provider !== 'kimi' &&
868
922
  provider !== 'glm' &&
869
- provider !== 'gemini'
923
+ provider !== 'gemini' &&
924
+ provider !== 'xai' &&
925
+ provider !== 'meta'
870
926
  ) {
871
927
  return { ok: false, error: `unsupported model provider '${String(provider)}'` }
872
928
  }
@@ -968,6 +1024,175 @@ async function handleInterviews(req: IncomingMessage, res: ServerResponse, pathn
968
1024
  }
969
1025
  }
970
1026
 
1027
+ // "Describe a workflow" (loopengine's web/workflow-admin.ts): what the planner and the
1028
+ // checks know about each agent: its tools, abilities, rules and primary
1029
+ // messaging app.
1030
+ async function workflowAgentInfo(): Promise<WorkflowAgentInfo[]> {
1031
+ return Promise.all(
1032
+ listAgents().flatMap((name) => {
1033
+ const entry = getEntry(name)
1034
+ if (!entry) return []
1035
+ return [(async (): Promise<WorkflowAgentInfo> => {
1036
+ const { rules, defaultDecision } = readActauthConfig(name)
1037
+ // Abilities by default: gateway tools (Composio and the like) are
1038
+ // left out, so a workflow is never built on one.
1039
+ let tools: string[] = []
1040
+ let gateway = new Set<string>()
1041
+ try {
1042
+ gateway = new Set((await loadGatewayToolsFromDir(agentDir(name))).map((t) => t.name))
1043
+ const own = entry.config.tools ?? (await loadDefaultTools(entry.config))
1044
+ const all = [...own, ...(await loadSubagentAsTools(entry.config))]
1045
+ tools = [...new Set(all.map((t) => t.name))].filter((t) => !gateway.has(t))
1046
+ } catch {}
1047
+ let abilities: string[] = []
1048
+ try {
1049
+ abilities = listInstalledAbilities(name).map((a) => a.name)
1050
+ } catch {}
1051
+ let messaging: string | null = null
1052
+ try {
1053
+ const n = describeAgentNotifier(entry.config, defaultWebhook)
1054
+ messaging = n.source === 'none' ? null : (n.primary ?? n.channel ?? null)
1055
+ } catch {}
1056
+ const decided = (d: unknown): d is 'allow' | 'ask' | 'deny' => d === 'allow' || d === 'ask' || d === 'deny'
1057
+ return {
1058
+ name,
1059
+ tools,
1060
+ abilities,
1061
+ rules: rules.flatMap((r) => (decided(r.decision) && !gateway.has(r.tool) ? [{ tool: r.tool, decision: r.decision, scope: r.scope }] : [])),
1062
+ defaultDecision: decided(defaultDecision) ? defaultDecision : 'ask',
1063
+ messaging,
1064
+ }
1065
+ })()]
1066
+ }),
1067
+ )
1068
+ }
1069
+
1070
+ async function handleWorkflows(req: IncomingMessage, res: ServerResponse, pathname: string): Promise<void> {
1071
+ const json = (status: number, body: unknown): void => {
1072
+ res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(JSON.stringify(body))
1073
+ }
1074
+ const [action, jobId] = pathname.split('/').slice(2) // after /workflows
1075
+ const dirOf = (name: string) => join(projectDir(), 'agents', name)
1076
+ try {
1077
+ // No model to plan with until an agent names one and its key is set
1078
+ // (building the model call throws without the key).
1079
+ let picked: ReturnType<typeof pickInterviewModel>
1080
+ try {
1081
+ picked = pickInterviewModel(interviewAgents())
1082
+ } catch {
1083
+ picked = undefined
1084
+ }
1085
+ if (!action && req.method === 'GET') return json(200, { ready: Boolean(picked), canInstallAbilities: secretsProtected, agents: listAgents() })
1086
+ // A planning job's progress (the page checks every few seconds).
1087
+ if (action === 'plan' && jobId && req.method === 'GET') {
1088
+ const job = getPlanJob(jobId)
1089
+ return job ? json(200, job) : json(404, { error: 'No such planning job (it may have expired); plan it again.' })
1090
+ }
1091
+ if (action === 'plan' && !jobId && req.method === 'POST') {
1092
+ if (!picked) return json(409, { error: 'No model is ready to plan with: set one on an agent (Overview → Model) and add its key under Models.' })
1093
+ const body = await readJsonBody(req)
1094
+ const base = interviewTools(interviewAgents())
1095
+ // The same model, with room for a whole plan in one reply (an
1096
+ // agent's own model call stops at its maxTokens, often 4096).
1097
+ let modelCall = picked.modelCall
1098
+ try {
1099
+ modelCall = (await synthesizeCreateModelCall({ ...picked.model, maxTokens: 8192 } as AgentModelConfig))()
1100
+ } catch {}
1101
+ if (typeof body.description !== 'string' || body.description.trim().length < 15) {
1102
+ return json(400, { error: 'Describe the workflow in a sentence or two: what starts it, the steps, and where you want to decide.' })
1103
+ }
1104
+ const job = startPlanJob(() => planWorkflow(body.description, modelCall, {
1105
+ agentInfo: workflowAgentInfo,
1106
+ // A summary: an agent's first tools and its rules that aren't the
1107
+ // default, to keep what the model reads short.
1108
+ listAgents: async () => (await workflowAgentInfo()).map((a) => ({
1109
+ name: a.name,
1110
+ abilities: a.abilities,
1111
+ messaging: a.messaging,
1112
+ defaultDecision: a.defaultDecision,
1113
+ toolCount: a.tools.length,
1114
+ tools: a.tools.slice(0, 40),
1115
+ rules: a.rules.filter((r) => r.decision !== a.defaultDecision).slice(0, 40).map((r) => `${r.tool}: ${r.decision}`),
1116
+ })),
1117
+ searchPublicAbilities: (query) => base.searchPublicAbilities(query),
1118
+ listWorkspaceAbilities: async () => listWorkspaceAbilities().map((a) => ({ name: a.name, description: a.description, spec: a.spec })),
1119
+ abilityDetails: async (spec) => {
1120
+ const d = await abilityDetails(spec)
1121
+ return { name: d.name, tools: d.tools.map((t) => ({ name: t.name, description: t.description.slice(0, 300), decision: t.decision ?? null })), settings: d.settings.map((x) => x.name) }
1122
+ },
1123
+ listConnections: () => base.listConnections(),
1124
+ }))
1125
+ return json(202, job)
1126
+ }
1127
+ if (action === 'create' && req.method === 'POST') {
1128
+ const body = await readJsonBody(req)
1129
+ const model = pickAgentModel(interviewAgents())
1130
+ const created = await createWorkflow(body, {
1131
+ agentExists: (name) => Boolean(getEntry(name)) || existsSync(dirOf(name)),
1132
+ agentDir: dirOf,
1133
+ agentInfo: workflowAgentInfo,
1134
+ scaffoldAgent: async (name, systemPrompt) => {
1135
+ const parsed = parseAgentTemplateOptions({ systemPrompt, ...(model ? { model } : {}) })
1136
+ if (!parsed.ok) throw new WorkflowError(parsed.error)
1137
+ await scaffoldAgent(projectDir(), name, parsed.value)
1138
+ },
1139
+ registerAgent: async (name) => {
1140
+ const indexPath = join(dirOf(name), 'index.ts')
1141
+ registerAgent(await loadAgentModule(indexPath, indexPath))
1142
+ },
1143
+ // Skills, rules and gateway tools are read on every run, so a
1144
+ // loaded agent already sees them; new ability code restarts the
1145
+ // server (exitIfCodeChanged). Only an agent not loaded yet is.
1146
+ reloadAgent: async (name) => {
1147
+ if (getEntry(name)) return
1148
+ const indexPath = join(dirOf(name), 'index.ts')
1149
+ registerAgent(await loadAgentModule(indexPath, indexPath))
1150
+ },
1151
+ // As for templates and the Abilities tab: installing runs
1152
+ // third-party code, so only behind the admin login.
1153
+ ...(secretsProtected ? { installAbility: async (name: string, spec: string) => void (await installAbility(name, spec)) } : {}),
1154
+ envValue: (name, variable) => createAgentEnv(name, dirOf(name)).get(variable),
1155
+ messagingConfigured: (name) => {
1156
+ const entry = getEntry(name)
1157
+ return entry ? describeAgentNotifier(entry.config, defaultWebhook).source !== 'none' : false
1158
+ },
1159
+ })
1160
+ json(200, created)
1161
+ // New tools in an agent that was already running load with a restart,
1162
+ // as after installing an ability from its tab.
1163
+ if (!created.newAgent && created.abilities.installed.length) exitIfCodeChanged(res)
1164
+ return
1165
+ }
1166
+ return json(404, { error: 'not found' })
1167
+ } catch (err) {
1168
+ const message = err instanceof Error ? err.message : String(err)
1169
+ if (err instanceof WorkflowError) return json(400, { error: message })
1170
+ console.error('[loopengine] workflow failed:', err)
1171
+ return json(502, { error: `Couldn't plan or create the workflow: ${message}` })
1172
+ }
1173
+ }
1174
+
1175
+ // An agent's workflows (its Overview): list, run now, forget.
1176
+ async function handleAgentWorkflows(req: IncomingMessage, res: ServerResponse, agentName: string, id: string | undefined, action: string | undefined): Promise<void> {
1177
+ const json = (status: number, body: unknown): void => {
1178
+ res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(JSON.stringify(body))
1179
+ }
1180
+ if (!getEntry(agentName)) return json(404, { error: `unknown agent '${agentName}'` })
1181
+ const dir = agentDir(agentName)
1182
+ try {
1183
+ if (!id && req.method === 'GET') return json(200, { workflows: await listAgentWorkflows(agentName, dir) })
1184
+ if (id && action === 'run' && req.method === 'POST') return json(200, await runWorkflowNow(agentName, dir, id))
1185
+ if (id && !action && req.method === 'DELETE') {
1186
+ forgetWorkflow(dir, id)
1187
+ return json(200, { workflows: await listAgentWorkflows(agentName, dir) })
1188
+ }
1189
+ return json(404, { error: 'not found' })
1190
+ } catch (err) {
1191
+ const message = err instanceof Error ? err.message : String(err)
1192
+ return json(err instanceof WorkflowError ? 400 : 500, { error: message })
1193
+ }
1194
+ }
1195
+
971
1196
  // Templates (loopengine's web/templates-admin.ts): ready-made agents,
972
1197
  // created through the same scaffold and registration as Agents → + New.
973
1198
  // A template agent takes the model of an agent that already works here,
@@ -1367,7 +1592,8 @@ async function handleNotifierPut(req: IncomingMessage, res: ServerResponse, agen
1367
1592
  res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ...describeAgentNotifier(entry.config, defaultWebhook), canSave: secretsProtected }))
1368
1593
  }
1369
1594
 
1370
- function handleNotifierDelete(res: ServerResponse, agentName: string): void {
1595
+ // `channel` removes just that app's connection; without it, all of them.
1596
+ function handleNotifierDelete(res: ServerResponse, agentName: string, channel?: string): void {
1371
1597
  if (!secretsProtected) {
1372
1598
  res.writeHead(403, { 'content-type': 'application/json' }).end(
1373
1599
  JSON.stringify({ error: 'Refusing to remove a notifier without LOOPENGINE_ADMIN_AUTH configured.' }),
@@ -1379,17 +1605,42 @@ function handleNotifierDelete(res: ServerResponse, agentName: string): void {
1379
1605
  res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
1380
1606
  return
1381
1607
  }
1382
- removeAgentNotifier(entry.config)
1608
+ removeAgentNotifier(entry.config, channel)
1383
1609
  res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ...describeAgentNotifier(entry.config, defaultWebhook), canSave: secretsProtected }))
1384
1610
  }
1385
1611
 
1386
- async function handleNotifierTest(res: ServerResponse, agentName: string): Promise<void> {
1612
+ // A body naming a `channel` tests that app's saved connection, primary
1613
+ // or not; without one, whichever notifier applies.
1614
+ async function handleNotifierTest(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
1615
+ const entry = getEntry(agentName)
1616
+ if (!entry) {
1617
+ res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
1618
+ return
1619
+ }
1620
+ const body = await readJsonBody(req).catch(() => ({}) as Record<string, unknown>)
1621
+ const channel = typeof body.channel === 'string' ? body.channel : undefined
1622
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(await testAgentNotifier(entry.config, defaultWebhook, channel)))
1623
+ }
1624
+
1625
+ // The Messaging tab's "Make primary": which saved connection is used.
1626
+ async function handleNotifierPrimaryPut(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
1627
+ if (!secretsProtected) {
1628
+ res.writeHead(403, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'Refusing to change the notifier without LOOPENGINE_ADMIN_AUTH configured.' }))
1629
+ return
1630
+ }
1387
1631
  const entry = getEntry(agentName)
1388
1632
  if (!entry) {
1389
1633
  res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
1390
1634
  return
1391
1635
  }
1392
- res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(await testAgentNotifier(entry.config, defaultWebhook)))
1636
+ try {
1637
+ setAgentPrimaryNotifier(entry.config, (await readJsonBody(req)).channel)
1638
+ } catch (err) {
1639
+ const status = err instanceof NotifierFileError ? 400 : 500
1640
+ res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
1641
+ return
1642
+ }
1643
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ...describeAgentNotifier(entry.config, defaultWebhook), canSave: secretsProtected }))
1393
1644
  }
1394
1645
 
1395
1646
  // Backs the console's Slack channel picker. A bot token typed into the form
@@ -1585,6 +1836,10 @@ async function handleAbilitiesGet(res: ServerResponse, agentName: string): Promi
1585
1836
  return
1586
1837
  }
1587
1838
  const installed = listInstalledAbilities(agentName)
1839
+ let sources: AbilitySource[] = []
1840
+ try {
1841
+ sources = readAbilitySources()
1842
+ } catch {}
1588
1843
  const abilities = await Promise.all(
1589
1844
  installed.map(async (a) => {
1590
1845
  // a.dependencies is only ever undefined for a record from before
@@ -1594,9 +1849,13 @@ async function handleAbilitiesGet(res: ServerResponse, agentName: string): Promi
1594
1849
  // otherwise permanently hide "Install deps" for an ability that
1595
1850
  // genuinely still needs something.
1596
1851
  const dependencies = a.dependencies !== undefined ? a.dependencies : ((await backfillDependencies(agentName, a.name)) ?? [])
1597
- const latestVersion = await fetchLatestAbilityVersion(a.name)
1852
+ // Public or private, for the console's badge. Only a public one is
1853
+ // checked against npm for a newer version: a private ability could
1854
+ // share its name with an unrelated public package.
1855
+ const origin = abilityOrigin(a.spec, a.name, sources)
1856
+ const latestVersion = origin.kind === 'public' ? await fetchLatestAbilityVersion(a.name) : null
1598
1857
  const missingDependencies = dependencies.filter((dep) => !hasNodeModule(dep))
1599
- return { ...a, dependencies, latestVersion, upToDate: latestVersion !== null && latestVersion === a.version, missingDependencies }
1858
+ return { ...a, origin, dependencies, latestVersion, upToDate: latestVersion !== null && latestVersion === a.version, missingDependencies }
1600
1859
  }),
1601
1860
  )
1602
1861
  res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ abilities }))
@@ -1786,12 +2045,86 @@ function parseActauthRuleBody(body: Record<string, unknown>, requireName: boolea
1786
2045
  return { ok: true, value: { name: typeof body.name === 'string' ? body.name : '', scope: body.scope, tool: body.tool, decision: body.decision } }
1787
2046
  }
1788
2047
 
1789
- function handleActauthGet(res: ServerResponse, agentName: string): void {
2048
+ // The Permissions data. `rules`: every rule, each with the ability it
2049
+ // belongs to (empty for none): the one that added it when installed, else
2050
+ // the one whose tool it's for. Edited in full from the Advanced tab.
2051
+ // `tools`: every tool the agent has (its own, subagents, Composio), each
2052
+ // with its ability and decision: its rule for everyone (*/*), else its
2053
+ // first rule, else the default; `scopeNote` says when that isn't the whole
2054
+ // story, so the Permissions tab doesn't look like it has duplicates.
2055
+ async function actauthConfigWithAbilities(agentName: string) {
2056
+ const config = readActauthConfig(agentName)
2057
+ let installed: Array<{ name: string; tools: string[]; actauthRules: string[] }> = []
2058
+ try {
2059
+ installed = listInstalledAbilities(agentName)
2060
+ } catch {}
2061
+ const abilityFor = (tool: string, ruleName?: string) =>
2062
+ (installed.find((a) => ruleName && a.actauthRules.includes(ruleName)) ?? installed.find((a) => a.tools.includes(tool)))?.name ?? null
2063
+ const rules = config.rules.map((rule) => ({ ...rule, ability: abilityFor(rule.tool, rule.name) }))
2064
+
2065
+ const entry = getEntry(agentName)
2066
+ let toolNames: string[] = []
2067
+ if (entry) {
2068
+ try {
2069
+ const own = entry.config.tools ?? (await loadDefaultTools(entry.config))
2070
+ const all = [...own, ...(await loadSubagentAsTools(entry.config)), ...(await loadGatewayToolsFromDir(agentDir(agentName)))]
2071
+ toolNames = [...new Set(all.map((t) => t.name))]
2072
+ } catch {}
2073
+ }
2074
+ const tools = toolNames.map((tool) => {
2075
+ const forTool = rules.filter((r) => r.tool === tool)
2076
+ const rule = forTool.find((r) => r.scope === '*/*') ?? forTool[0]
2077
+ const others = forTool.length - (rule ? 1 : 0)
2078
+ const scopeNote = rule && rule.scope !== '*/*' ? `${rule.scope} only` : others ? `+${others} scoped ${others === 1 ? 'rule' : 'rules'} in Advanced` : null
2079
+ return {
2080
+ tool,
2081
+ ability: rule?.ability ?? abilityFor(tool),
2082
+ decision: rule?.decision ?? config.defaultDecision,
2083
+ isDefault: !rule,
2084
+ // A rule with no name can't be changed here (rules are changed by name).
2085
+ editable: !rule || Boolean(rule.name),
2086
+ scopeNote,
2087
+ }
2088
+ })
2089
+ return { ...config, rules, tools }
2090
+ }
2091
+
2092
+ // The Permissions tab's switch for one tool: updates its rule for everyone
2093
+ // (*/*), or adds one (tool-<name>) when it has none.
2094
+ async function handleActauthToolPut(req: IncomingMessage, res: ServerResponse, agentName: string, tool: string): Promise<void> {
1790
2095
  if (!getEntry(agentName)) {
1791
2096
  res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
1792
2097
  return
1793
2098
  }
1794
- res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(readActauthConfig(agentName)))
2099
+ const body = await readJsonBody(req)
2100
+ if (!isDecision(body.decision)) {
2101
+ res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: "decision must be 'allow', 'ask', or 'deny'" }))
2102
+ return
2103
+ }
2104
+ const { rules } = readActauthConfig(agentName)
2105
+ const existing = rules.find((r) => r.tool === tool && r.scope === '*/*' && r.name)
2106
+ try {
2107
+ if (existing) {
2108
+ updateActauthRule(agentName, existing.name!, { scope: existing.scope, tool, decision: body.decision })
2109
+ } else {
2110
+ const base = `tool-${tool}`.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').slice(0, 80)
2111
+ let name = base
2112
+ for (let i = 2; rules.some((r) => r.name === name); i++) name = `${base}-${i}`
2113
+ addActauthRule(agentName, { name, scope: '*/*', tool, decision: body.decision })
2114
+ }
2115
+ } catch (err) {
2116
+ res.writeHead(500, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
2117
+ return
2118
+ }
2119
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(await actauthConfigWithAbilities(agentName)))
2120
+ }
2121
+
2122
+ async function handleActauthGet(res: ServerResponse, agentName: string): Promise<void> {
2123
+ if (!getEntry(agentName)) {
2124
+ res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
2125
+ return
2126
+ }
2127
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(await actauthConfigWithAbilities(agentName)))
1795
2128
  }
1796
2129
 
1797
2130
  async function handleActauthRulePost(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
@@ -1812,7 +2145,7 @@ async function handleActauthRulePost(req: IncomingMessage, res: ServerResponse,
1812
2145
  res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
1813
2146
  return
1814
2147
  }
1815
- res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(readActauthConfig(agentName)))
2148
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(await actauthConfigWithAbilities(agentName)))
1816
2149
  }
1817
2150
 
1818
2151
  async function handleActauthRulePut(req: IncomingMessage, res: ServerResponse, agentName: string, ruleName: string): Promise<void> {
@@ -1833,10 +2166,10 @@ async function handleActauthRulePut(req: IncomingMessage, res: ServerResponse, a
1833
2166
  res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
1834
2167
  return
1835
2168
  }
1836
- res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(readActauthConfig(agentName)))
2169
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(await actauthConfigWithAbilities(agentName)))
1837
2170
  }
1838
2171
 
1839
- function handleActauthRuleDelete(res: ServerResponse, agentName: string, ruleName: string): void {
2172
+ async function handleActauthRuleDelete(res: ServerResponse, agentName: string, ruleName: string): Promise<void> {
1840
2173
  if (!getEntry(agentName)) {
1841
2174
  res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
1842
2175
  return
@@ -1848,7 +2181,7 @@ function handleActauthRuleDelete(res: ServerResponse, agentName: string, ruleNam
1848
2181
  res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
1849
2182
  return
1850
2183
  }
1851
- res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(readActauthConfig(agentName)))
2184
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(await actauthConfigWithAbilities(agentName)))
1852
2185
  }
1853
2186
 
1854
2187
  async function handleActauthDefaultDecisionPut(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
@@ -1862,7 +2195,7 @@ async function handleActauthDefaultDecisionPut(req: IncomingMessage, res: Server
1862
2195
  return
1863
2196
  }
1864
2197
  setDefaultDecision(agentName, body.decision)
1865
- res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(readActauthConfig(agentName)))
2198
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(await actauthConfigWithAbilities(agentName)))
1866
2199
  }
1867
2200
 
1868
2201
  // GET /agents content-negotiates on this: a browser navigating there sends
@@ -3334,6 +3667,20 @@ const server = createServer(async (req, res) => {
3334
3667
  await handleInterviews(req, res, pathname)
3335
3668
  return
3336
3669
  }
3670
+ // "Describe a workflow": the page, and its routes.
3671
+ if (req.method === 'GET' && pathname === '/workflows/new') {
3672
+ res.writeHead(200, { 'content-type': 'text/html' }).end(workflowPageHtml)
3673
+ return
3674
+ }
3675
+ if (pathname === '/workflows' || pathname === '/workflows/plan' || pathname.startsWith('/workflows/plan/') || pathname === '/workflows/create') {
3676
+ await handleWorkflows(req, res, pathname)
3677
+ return
3678
+ }
3679
+ const agentWorkflowsMatch = pathname.match(/^\/agents\/([^/]+)\/workflows(?:\/([a-z0-9-]+))?(?:\/(run))?$/)
3680
+ if (agentWorkflowsMatch) {
3681
+ await handleAgentWorkflows(req, res, decodeURIComponent(agentWorkflowsMatch[1]), agentWorkflowsMatch[2], agentWorkflowsMatch[3])
3682
+ return
3683
+ }
3337
3684
  // Templates: the page, and its routes.
3338
3685
  if (req.method === 'GET' && pathname === '/templates') {
3339
3686
  res.writeHead(200, { 'content-type': 'text/html' }).end(templatesPageHtml)
@@ -3416,6 +3763,19 @@ const server = createServer(async (req, res) => {
3416
3763
  )
3417
3764
  return
3418
3765
  }
3766
+ // The model dropdown in the New agent and Model forms: what the
3767
+ // provider lists for the saved key. Never includes the key itself.
3768
+ const providerModelsMatch = req.method === 'GET' && pathname.match(/^\/config\/models\/([a-z]+)\/models$/)
3769
+ if (providerModelsMatch) {
3770
+ try {
3771
+ const view = await listProviderModels(providerModelsMatch[1])
3772
+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(JSON.stringify(view))
3773
+ } catch (err) {
3774
+ const status = err instanceof ModelKeyError ? 400 : 500
3775
+ res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
3776
+ }
3777
+ return
3778
+ }
3419
3779
  // A provider's API key, from the Models page: write-only, never read
3420
3780
  // back. Same rule as the Environment tab: refused on a server with no
3421
3781
  // auth, since anyone who could reach it could replace the key.
@@ -3608,12 +3968,17 @@ const server = createServer(async (req, res) => {
3608
3968
  return
3609
3969
  }
3610
3970
  if (notifierMatch && req.method === 'DELETE') {
3611
- handleNotifierDelete(res, decodeURIComponent(notifierMatch[1]))
3971
+ handleNotifierDelete(res, decodeURIComponent(notifierMatch[1]), new URL(req.url ?? '/', 'http://localhost').searchParams.get('channel') ?? undefined)
3612
3972
  return
3613
3973
  }
3614
3974
  const notifierTestMatch = req.method === 'POST' && pathname.match(/^\/agents\/([^/]+)\/notifier\/test$/)
3615
3975
  if (notifierTestMatch) {
3616
- await handleNotifierTest(res, decodeURIComponent(notifierTestMatch[1]))
3976
+ await handleNotifierTest(req, res, decodeURIComponent(notifierTestMatch[1]))
3977
+ return
3978
+ }
3979
+ const notifierPrimaryMatch = req.method === 'PUT' && pathname.match(/^\/agents\/([^/]+)\/notifier\/primary$/)
3980
+ if (notifierPrimaryMatch) {
3981
+ await handleNotifierPrimaryPut(req, res, decodeURIComponent(notifierPrimaryMatch[1]))
3617
3982
  return
3618
3983
  }
3619
3984
  // Backs the console's Schedules tab — see handleSchedulesGet above.
@@ -3683,6 +4048,105 @@ const server = createServer(async (req, res) => {
3683
4048
  // itself need to be excluded from matching as an agent name (it
3684
4049
  // never would be one in practice, but this ordering means never
3685
4050
  // having to think about it).
4051
+ // Private ability sources (Settings) and what they offer (the
4052
+ // Abilities page's Private section): web/ability-sources-admin.ts.
4053
+ // Saving or removing one stores a token, so it's refused without auth,
4054
+ // like a model key.
4055
+ if (pathname === '/config/ability-sources' && req.method === 'GET') {
4056
+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(JSON.stringify({ ...describeAbilitySources(), canSave: secretsProtected }))
4057
+ return
4058
+ }
4059
+ const abilitySourceMatch = pathname.match(/^\/config\/ability-sources(?:\/([a-z0-9-]+))?$/)
4060
+ if (abilitySourceMatch && (req.method === 'PUT' || req.method === 'DELETE')) {
4061
+ if (!secretsProtected) {
4062
+ res.writeHead(403, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'Refusing to change ability sources without LOOPENGINE_ADMIN_AUTH configured: set it first, or edit ability-sources.yml and .env.' }))
4063
+ return
4064
+ }
4065
+ try {
4066
+ if (req.method === 'PUT') await saveAbilitySourceFromConsole(await readJsonBody(req))
4067
+ else if (abilitySourceMatch[1]) await removeAbilitySourceFromConsole(abilitySourceMatch[1])
4068
+ else throw new AbilitySourceError('Name the source to remove')
4069
+ } catch (err) {
4070
+ const status = err instanceof AbilitySourceError ? 400 : 500
4071
+ res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
4072
+ return
4073
+ }
4074
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ...describeAbilitySources(), canSave: secretsProtected }))
4075
+ return
4076
+ }
4077
+ // What an ability can do: its tools (what each does, its permission)
4078
+ // and skills. `agent` adds that agent's current decisions.
4079
+ if (pathname === '/abilities/details' && req.method === 'GET') {
4080
+ const params = new URL(req.url ?? '/', 'http://localhost').searchParams
4081
+ const spec = params.get('spec')?.trim()
4082
+ const agent = params.get('agent') ?? undefined
4083
+ if (!spec || (agent && !getEntry(agent))) {
4084
+ res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: spec ? `unknown agent '${agent}'` : 'spec is required' }))
4085
+ return
4086
+ }
4087
+ try {
4088
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(await abilityDetails(spec, { agent })))
4089
+ } catch (err) {
4090
+ res.writeHead(502, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
4091
+ }
4092
+ return
4093
+ }
4094
+ // The workspace's private abilities (core/workspace-abilities.ts):
4095
+ // installed from the Abilities page, added to agents from their tab.
4096
+ if (pathname === '/abilities/workspace' && req.method === 'GET') {
4097
+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(JSON.stringify({ abilities: listWorkspaceAbilities() }))
4098
+ return
4099
+ }
4100
+ if (pathname === '/abilities/workspace' && (req.method === 'POST' || req.method === 'DELETE')) {
4101
+ try {
4102
+ if (req.method === 'POST') {
4103
+ const spec = (await readJsonBody(req)).spec
4104
+ if (typeof spec !== 'string' || !spec.trim()) throw new WorkspaceAbilityError('spec is required')
4105
+ await installWorkspaceAbility(spec.trim())
4106
+ } else {
4107
+ removeWorkspaceAbility(new URL(req.url ?? '/', 'http://localhost').searchParams.get('name') ?? '')
4108
+ }
4109
+ } catch (err) {
4110
+ const status = err instanceof WorkspaceAbilityError ? 400 : 502
4111
+ res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
4112
+ return
4113
+ }
4114
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ abilities: listWorkspaceAbilities() }))
4115
+ return
4116
+ }
4117
+ // An ability uploaded from the browser (a .zip/.tgz, or a folder's
4118
+ // files, base64), stored in the workspace like one from a source.
4119
+ if (pathname === '/abilities/workspace/upload' && req.method === 'POST') {
4120
+ try {
4121
+ // Base64 adds a third, plus the JSON around it.
4122
+ const body = await readLimitedJsonBody(req, Math.ceil(MAX_UPLOAD_BYTES * 1.4) + 64 * 1024)
4123
+ const upload: AbilityUpload =
4124
+ body.kind === 'files' && Array.isArray(body.files)
4125
+ ? { kind: 'files', folderName: typeof body.folderName === 'string' ? body.folderName : undefined, files: (body.files as Array<{ path?: unknown; data?: unknown }>).map((f) => ({ path: String(f?.path ?? ''), data: String(f?.data ?? '') })) }
4126
+ : { kind: 'archive', fileName: String(body.fileName ?? ''), data: String(body.data ?? '') }
4127
+ await installUploadedAbility(upload)
4128
+ } catch (err) {
4129
+ const status = err instanceof WorkspaceAbilityError ? 400 : 500
4130
+ res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
4131
+ return
4132
+ }
4133
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ abilities: listWorkspaceAbilities() }))
4134
+ return
4135
+ }
4136
+ // Public abilities on npm, for the Abilities page (no agent involved).
4137
+ if (pathname === '/abilities/search' && req.method === 'GET') {
4138
+ try {
4139
+ const results = await searchPublicAbilities(new URL(req.url ?? '/', 'http://localhost').searchParams.get('q') ?? undefined)
4140
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ results }))
4141
+ } catch (err) {
4142
+ res.writeHead(502, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
4143
+ }
4144
+ return
4145
+ }
4146
+ if (pathname === '/abilities/private' && req.method === 'GET') {
4147
+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(JSON.stringify(await listPrivateAbilities()))
4148
+ return
4149
+ }
3686
4150
  const abilitiesSearchMatch = pathname.match(/^\/agents\/([^/]+)\/abilities\/search$/)
3687
4151
  if (abilitiesSearchMatch && req.method === 'GET') {
3688
4152
  const query = new URL(req.url ?? '/', 'http://localhost').searchParams.get('q')
@@ -3729,13 +4193,19 @@ const server = createServer(async (req, res) => {
3729
4193
  return
3730
4194
  }
3731
4195
 
4196
+ const actauthToolMatch = req.method === 'PUT' && pathname.match(/^\/agents\/([^/]+)\/actauth\/tools\/([^/]+)$/)
4197
+ if (actauthToolMatch) {
4198
+ await handleActauthToolPut(req, res, decodeURIComponent(actauthToolMatch[1]), decodeURIComponent(actauthToolMatch[2]))
4199
+ return
4200
+ }
4201
+
3732
4202
  const actauthRuleMatch = pathname.match(/^\/agents\/([^/]+)\/actauth\/rules\/([^/]+)$/)
3733
4203
  if (actauthRuleMatch && req.method === 'PUT') {
3734
4204
  await handleActauthRulePut(req, res, decodeURIComponent(actauthRuleMatch[1]), decodeURIComponent(actauthRuleMatch[2]))
3735
4205
  return
3736
4206
  }
3737
4207
  if (actauthRuleMatch && req.method === 'DELETE') {
3738
- handleActauthRuleDelete(res, decodeURIComponent(actauthRuleMatch[1]), decodeURIComponent(actauthRuleMatch[2]))
4208
+ await handleActauthRuleDelete(res, decodeURIComponent(actauthRuleMatch[1]), decodeURIComponent(actauthRuleMatch[2]))
3739
4209
  return
3740
4210
  }
3741
4211
 
@@ -3747,7 +4217,7 @@ const server = createServer(async (req, res) => {
3747
4217
 
3748
4218
  const actauthMatch = req.method === 'GET' && pathname.match(/^\/agents\/([^/]+)\/actauth$/)
3749
4219
  if (actauthMatch) {
3750
- handleActauthGet(res, decodeURIComponent(actauthMatch[1]))
4220
+ await handleActauthGet(res, decodeURIComponent(actauthMatch[1]))
3751
4221
  return
3752
4222
  }
3753
4223
 
@@ -9,7 +9,7 @@
9
9
  "cli": "tsx adapters/cli.ts"
10
10
  },
11
11
  "dependencies": {
12
- "loopengine": "^0.1.94",
12
+ "loopengine": "^0.1.97",
13
13
  "actauth": "^0.0.12",
14
14
  "tsx": "^4.19.0",
15
15
  "typescript": "^5.7.0"