create-loopengine 0.1.75 → 0.1.77

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.75",
3
+ "version": "0.1.77",
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,22 @@ import {
189
190
  AbilityNotInstalledError,
190
191
  searchPublicAbilities,
191
192
  fetchLatestAbilityVersion,
193
+ describeAbilitySources,
194
+ listPrivateAbilities,
195
+ removeAbilitySourceFromConsole,
196
+ saveAbilitySourceFromConsole,
197
+ abilityDetails,
198
+ MAX_UPLOAD_BYTES,
199
+ installUploadedAbility,
200
+ installWorkspaceAbility,
201
+ type AbilityUpload,
202
+ WorkspaceAbilityError,
203
+ listWorkspaceAbilities,
204
+ removeWorkspaceAbility,
205
+ AbilitySourceError,
206
+ abilityOrigin,
207
+ readAbilitySources,
208
+ type AbilitySource,
192
209
  describeModelProviders,
193
210
  loopengineVersion,
194
211
  describeUpgrade,
@@ -196,7 +213,12 @@ import {
196
213
  UpgradeError,
197
214
  setModelProviderKey,
198
215
  clearModelProviderKey,
216
+ listProviderModels,
199
217
  ModelKeyError,
218
+ describeTimeZone,
219
+ setWorkspaceTimeZone,
220
+ TimeZoneError,
221
+ workspaceTimeZone,
200
222
  describeGateways,
201
223
  scaffoldAgent,
202
224
  AgentNameError,
@@ -315,6 +337,32 @@ function readRawBody(req: IncomingMessage): Promise<string> {
315
337
  })
316
338
  }
317
339
 
340
+ /** readJsonBody with a size cap, for uploads: a body past `maxBytes` is
341
+ * refused before it's all read. */
342
+ function readLimitedJsonBody(req: IncomingMessage, maxBytes: number): Promise<Record<string, unknown>> {
343
+ return new Promise((resolve, reject) => {
344
+ const chunks: Buffer[] = []
345
+ let size = 0
346
+ req.on('data', (chunk: Buffer) => {
347
+ size += chunk.length
348
+ if (size > maxBytes) {
349
+ reject(new WorkspaceAbilityError('The upload is larger than 25 MB'))
350
+ req.destroy()
351
+ return
352
+ }
353
+ chunks.push(chunk)
354
+ })
355
+ req.on('end', () => {
356
+ try {
357
+ resolve(chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {})
358
+ } catch (err) {
359
+ reject(err)
360
+ }
361
+ })
362
+ req.on('error', reject)
363
+ })
364
+ }
365
+
318
366
  function readJsonBody(req: IncomingMessage): Promise<Record<string, unknown>> {
319
367
  return new Promise((resolve, reject) => {
320
368
  let data = ''
@@ -862,7 +910,9 @@ function parseAgentTemplateOptions(body: Record<string, unknown>): { ok: true; v
862
910
  provider !== 'deepseek' &&
863
911
  provider !== 'kimi' &&
864
912
  provider !== 'glm' &&
865
- provider !== 'gemini'
913
+ provider !== 'gemini' &&
914
+ provider !== 'xai' &&
915
+ provider !== 'meta'
866
916
  ) {
867
917
  return { ok: false, error: `unsupported model provider '${String(provider)}'` }
868
918
  }
@@ -1294,7 +1344,7 @@ function scheduleJson(res: ServerResponse, status: number, body: unknown): void
1294
1344
 
1295
1345
  async function handleSchedulesGet(res: ServerResponse, agentName: string): Promise<void> {
1296
1346
  if (!getEntry(agentName)) return scheduleJson(res, 404, { error: `unknown agent '${agentName}'` })
1297
- scheduleJson(res, 200, { tasks: await listScheduledTasks({ agent: agentName }) })
1347
+ scheduleJson(res, 200, { tasks: await listScheduledTasks({ agent: agentName }), time_zone: workspaceTimeZone() })
1298
1348
  }
1299
1349
 
1300
1350
  async function handleSchedulesPost(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
@@ -1304,7 +1354,7 @@ async function handleSchedulesPost(req: IncomingMessage, res: ServerResponse, ag
1304
1354
  const task = await createScheduledTask({
1305
1355
  agent: agentName,
1306
1356
  message: String(body.message ?? ''),
1307
- schedule: (body.schedule ?? {}) as { run_at?: unknown; every?: unknown; start_at?: unknown },
1357
+ schedule: (body.schedule ?? {}) as { run_at?: unknown; every?: unknown; start_at?: unknown; at?: unknown; weekday?: unknown },
1308
1358
  task_name: typeof body.task_name === 'string' ? body.task_name : undefined,
1309
1359
  notify: body.notify === true,
1310
1360
  })
@@ -1363,7 +1413,8 @@ async function handleNotifierPut(req: IncomingMessage, res: ServerResponse, agen
1363
1413
  res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ...describeAgentNotifier(entry.config, defaultWebhook), canSave: secretsProtected }))
1364
1414
  }
1365
1415
 
1366
- function handleNotifierDelete(res: ServerResponse, agentName: string): void {
1416
+ // `channel` removes just that app's connection; without it, all of them.
1417
+ function handleNotifierDelete(res: ServerResponse, agentName: string, channel?: string): void {
1367
1418
  if (!secretsProtected) {
1368
1419
  res.writeHead(403, { 'content-type': 'application/json' }).end(
1369
1420
  JSON.stringify({ error: 'Refusing to remove a notifier without LOOPENGINE_ADMIN_AUTH configured.' }),
@@ -1375,17 +1426,42 @@ function handleNotifierDelete(res: ServerResponse, agentName: string): void {
1375
1426
  res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
1376
1427
  return
1377
1428
  }
1378
- removeAgentNotifier(entry.config)
1429
+ removeAgentNotifier(entry.config, channel)
1379
1430
  res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ...describeAgentNotifier(entry.config, defaultWebhook), canSave: secretsProtected }))
1380
1431
  }
1381
1432
 
1382
- async function handleNotifierTest(res: ServerResponse, agentName: string): Promise<void> {
1433
+ // A body naming a `channel` tests that app's saved connection, primary
1434
+ // or not; without one, whichever notifier applies.
1435
+ async function handleNotifierTest(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
1436
+ const entry = getEntry(agentName)
1437
+ if (!entry) {
1438
+ res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
1439
+ return
1440
+ }
1441
+ const body = await readJsonBody(req).catch(() => ({}) as Record<string, unknown>)
1442
+ const channel = typeof body.channel === 'string' ? body.channel : undefined
1443
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(await testAgentNotifier(entry.config, defaultWebhook, channel)))
1444
+ }
1445
+
1446
+ // The Messaging tab's "Make primary": which saved connection is used.
1447
+ async function handleNotifierPrimaryPut(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
1448
+ if (!secretsProtected) {
1449
+ res.writeHead(403, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'Refusing to change the notifier without LOOPENGINE_ADMIN_AUTH configured.' }))
1450
+ return
1451
+ }
1383
1452
  const entry = getEntry(agentName)
1384
1453
  if (!entry) {
1385
1454
  res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
1386
1455
  return
1387
1456
  }
1388
- res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(await testAgentNotifier(entry.config, defaultWebhook)))
1457
+ try {
1458
+ setAgentPrimaryNotifier(entry.config, (await readJsonBody(req)).channel)
1459
+ } catch (err) {
1460
+ const status = err instanceof NotifierFileError ? 400 : 500
1461
+ res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
1462
+ return
1463
+ }
1464
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ...describeAgentNotifier(entry.config, defaultWebhook), canSave: secretsProtected }))
1389
1465
  }
1390
1466
 
1391
1467
  // Backs the console's Slack channel picker. A bot token typed into the form
@@ -1581,6 +1657,10 @@ async function handleAbilitiesGet(res: ServerResponse, agentName: string): Promi
1581
1657
  return
1582
1658
  }
1583
1659
  const installed = listInstalledAbilities(agentName)
1660
+ let sources: AbilitySource[] = []
1661
+ try {
1662
+ sources = readAbilitySources()
1663
+ } catch {}
1584
1664
  const abilities = await Promise.all(
1585
1665
  installed.map(async (a) => {
1586
1666
  // a.dependencies is only ever undefined for a record from before
@@ -1590,9 +1670,13 @@ async function handleAbilitiesGet(res: ServerResponse, agentName: string): Promi
1590
1670
  // otherwise permanently hide "Install deps" for an ability that
1591
1671
  // genuinely still needs something.
1592
1672
  const dependencies = a.dependencies !== undefined ? a.dependencies : ((await backfillDependencies(agentName, a.name)) ?? [])
1593
- const latestVersion = await fetchLatestAbilityVersion(a.name)
1673
+ // Public or private, for the console's badge. Only a public one is
1674
+ // checked against npm for a newer version: a private ability could
1675
+ // share its name with an unrelated public package.
1676
+ const origin = abilityOrigin(a.spec, a.name, sources)
1677
+ const latestVersion = origin.kind === 'public' ? await fetchLatestAbilityVersion(a.name) : null
1594
1678
  const missingDependencies = dependencies.filter((dep) => !hasNodeModule(dep))
1595
- return { ...a, dependencies, latestVersion, upToDate: latestVersion !== null && latestVersion === a.version, missingDependencies }
1679
+ return { ...a, origin, dependencies, latestVersion, upToDate: latestVersion !== null && latestVersion === a.version, missingDependencies }
1596
1680
  }),
1597
1681
  )
1598
1682
  res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ abilities }))
@@ -1782,12 +1866,86 @@ function parseActauthRuleBody(body: Record<string, unknown>, requireName: boolea
1782
1866
  return { ok: true, value: { name: typeof body.name === 'string' ? body.name : '', scope: body.scope, tool: body.tool, decision: body.decision } }
1783
1867
  }
1784
1868
 
1785
- function handleActauthGet(res: ServerResponse, agentName: string): void {
1869
+ // The Permissions data. `rules`: every rule, each with the ability it
1870
+ // belongs to (empty for none): the one that added it when installed, else
1871
+ // the one whose tool it's for. Edited in full from the Advanced tab.
1872
+ // `tools`: every tool the agent has (its own, subagents, Composio), each
1873
+ // with its ability and decision: its rule for everyone (*/*), else its
1874
+ // first rule, else the default; `scopeNote` says when that isn't the whole
1875
+ // story, so the Permissions tab doesn't look like it has duplicates.
1876
+ async function actauthConfigWithAbilities(agentName: string) {
1877
+ const config = readActauthConfig(agentName)
1878
+ let installed: Array<{ name: string; tools: string[]; actauthRules: string[] }> = []
1879
+ try {
1880
+ installed = listInstalledAbilities(agentName)
1881
+ } catch {}
1882
+ const abilityFor = (tool: string, ruleName?: string) =>
1883
+ (installed.find((a) => ruleName && a.actauthRules.includes(ruleName)) ?? installed.find((a) => a.tools.includes(tool)))?.name ?? null
1884
+ const rules = config.rules.map((rule) => ({ ...rule, ability: abilityFor(rule.tool, rule.name) }))
1885
+
1886
+ const entry = getEntry(agentName)
1887
+ let toolNames: string[] = []
1888
+ if (entry) {
1889
+ try {
1890
+ const own = entry.config.tools ?? (await loadDefaultTools(entry.config))
1891
+ const all = [...own, ...(await loadSubagentAsTools(entry.config)), ...(await loadGatewayToolsFromDir(agentDir(agentName)))]
1892
+ toolNames = [...new Set(all.map((t) => t.name))]
1893
+ } catch {}
1894
+ }
1895
+ const tools = toolNames.map((tool) => {
1896
+ const forTool = rules.filter((r) => r.tool === tool)
1897
+ const rule = forTool.find((r) => r.scope === '*/*') ?? forTool[0]
1898
+ const others = forTool.length - (rule ? 1 : 0)
1899
+ const scopeNote = rule && rule.scope !== '*/*' ? `${rule.scope} only` : others ? `+${others} scoped ${others === 1 ? 'rule' : 'rules'} in Advanced` : null
1900
+ return {
1901
+ tool,
1902
+ ability: rule?.ability ?? abilityFor(tool),
1903
+ decision: rule?.decision ?? config.defaultDecision,
1904
+ isDefault: !rule,
1905
+ // A rule with no name can't be changed here (rules are changed by name).
1906
+ editable: !rule || Boolean(rule.name),
1907
+ scopeNote,
1908
+ }
1909
+ })
1910
+ return { ...config, rules, tools }
1911
+ }
1912
+
1913
+ // The Permissions tab's switch for one tool: updates its rule for everyone
1914
+ // (*/*), or adds one (tool-<name>) when it has none.
1915
+ async function handleActauthToolPut(req: IncomingMessage, res: ServerResponse, agentName: string, tool: string): Promise<void> {
1786
1916
  if (!getEntry(agentName)) {
1787
1917
  res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
1788
1918
  return
1789
1919
  }
1790
- res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(readActauthConfig(agentName)))
1920
+ const body = await readJsonBody(req)
1921
+ if (!isDecision(body.decision)) {
1922
+ res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: "decision must be 'allow', 'ask', or 'deny'" }))
1923
+ return
1924
+ }
1925
+ const { rules } = readActauthConfig(agentName)
1926
+ const existing = rules.find((r) => r.tool === tool && r.scope === '*/*' && r.name)
1927
+ try {
1928
+ if (existing) {
1929
+ updateActauthRule(agentName, existing.name!, { scope: existing.scope, tool, decision: body.decision })
1930
+ } else {
1931
+ const base = `tool-${tool}`.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').slice(0, 80)
1932
+ let name = base
1933
+ for (let i = 2; rules.some((r) => r.name === name); i++) name = `${base}-${i}`
1934
+ addActauthRule(agentName, { name, scope: '*/*', tool, decision: body.decision })
1935
+ }
1936
+ } catch (err) {
1937
+ res.writeHead(500, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
1938
+ return
1939
+ }
1940
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(await actauthConfigWithAbilities(agentName)))
1941
+ }
1942
+
1943
+ async function handleActauthGet(res: ServerResponse, agentName: string): Promise<void> {
1944
+ if (!getEntry(agentName)) {
1945
+ res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
1946
+ return
1947
+ }
1948
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(await actauthConfigWithAbilities(agentName)))
1791
1949
  }
1792
1950
 
1793
1951
  async function handleActauthRulePost(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
@@ -1808,7 +1966,7 @@ async function handleActauthRulePost(req: IncomingMessage, res: ServerResponse,
1808
1966
  res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
1809
1967
  return
1810
1968
  }
1811
- res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(readActauthConfig(agentName)))
1969
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(await actauthConfigWithAbilities(agentName)))
1812
1970
  }
1813
1971
 
1814
1972
  async function handleActauthRulePut(req: IncomingMessage, res: ServerResponse, agentName: string, ruleName: string): Promise<void> {
@@ -1829,10 +1987,10 @@ async function handleActauthRulePut(req: IncomingMessage, res: ServerResponse, a
1829
1987
  res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
1830
1988
  return
1831
1989
  }
1832
- res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(readActauthConfig(agentName)))
1990
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(await actauthConfigWithAbilities(agentName)))
1833
1991
  }
1834
1992
 
1835
- function handleActauthRuleDelete(res: ServerResponse, agentName: string, ruleName: string): void {
1993
+ async function handleActauthRuleDelete(res: ServerResponse, agentName: string, ruleName: string): Promise<void> {
1836
1994
  if (!getEntry(agentName)) {
1837
1995
  res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
1838
1996
  return
@@ -1844,7 +2002,7 @@ function handleActauthRuleDelete(res: ServerResponse, agentName: string, ruleNam
1844
2002
  res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
1845
2003
  return
1846
2004
  }
1847
- res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(readActauthConfig(agentName)))
2005
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(await actauthConfigWithAbilities(agentName)))
1848
2006
  }
1849
2007
 
1850
2008
  async function handleActauthDefaultDecisionPut(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
@@ -1858,7 +2016,7 @@ async function handleActauthDefaultDecisionPut(req: IncomingMessage, res: Server
1858
2016
  return
1859
2017
  }
1860
2018
  setDefaultDecision(agentName, body.decision)
1861
- res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(readActauthConfig(agentName)))
2019
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(await actauthConfigWithAbilities(agentName)))
1862
2020
  }
1863
2021
 
1864
2022
  // GET /agents content-negotiates on this: a browser navigating there sends
@@ -3374,6 +3532,7 @@ const server = createServer(async (req, res) => {
3374
3532
  environment: process.env.LOOPENGINE_ENV ?? 'production',
3375
3533
  publicUrl: process.env.LOOPENGINE_PUBLIC_URL ?? null,
3376
3534
  scheduler: process.env.LOOPENGINE_SCHEDULER === 'off' ? 'off' : 'on',
3535
+ timeZone: workspaceTimeZone(),
3377
3536
  }),
3378
3537
  )
3379
3538
  return
@@ -3411,6 +3570,19 @@ const server = createServer(async (req, res) => {
3411
3570
  )
3412
3571
  return
3413
3572
  }
3573
+ // The model dropdown in the New agent and Model forms: what the
3574
+ // provider lists for the saved key. Never includes the key itself.
3575
+ const providerModelsMatch = req.method === 'GET' && pathname.match(/^\/config\/models\/([a-z]+)\/models$/)
3576
+ if (providerModelsMatch) {
3577
+ try {
3578
+ const view = await listProviderModels(providerModelsMatch[1])
3579
+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(JSON.stringify(view))
3580
+ } catch (err) {
3581
+ const status = err instanceof ModelKeyError ? 400 : 500
3582
+ res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
3583
+ }
3584
+ return
3585
+ }
3414
3586
  // A provider's API key, from the Models page: write-only, never read
3415
3587
  // back. Same rule as the Environment tab: refused on a server with no
3416
3588
  // auth, since anyone who could reach it could replace the key.
@@ -3433,6 +3605,29 @@ const server = createServer(async (req, res) => {
3433
3605
  res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true }))
3434
3606
  return
3435
3607
  }
3608
+ // Settings → Time zone (web/global-config.ts): what "9:00" means in
3609
+ // schedules. Saved like a model key, so refused without auth too.
3610
+ if (req.method === 'GET' && pathname === '/config/time-zone') {
3611
+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(JSON.stringify({ ...describeTimeZone(), canSave: secretsProtected }))
3612
+ return
3613
+ }
3614
+ if (req.method === 'PUT' && pathname === '/config/time-zone') {
3615
+ if (!secretsProtected) {
3616
+ res.writeHead(403, { 'content-type': 'application/json' }).end(
3617
+ JSON.stringify({ error: 'Refusing to change settings without LOOPENGINE_ADMIN_AUTH configured: set it first, or put LOOPENGINE_TIME_ZONE in .env.' }),
3618
+ )
3619
+ return
3620
+ }
3621
+ try {
3622
+ const body = await readJsonBody(req)
3623
+ const outcome = await setWorkspaceTimeZone(body.timeZone, { moveSchedules: body.moveSchedules === true })
3624
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true, ...outcome }))
3625
+ } catch (err) {
3626
+ const status = err instanceof TimeZoneError ? 400 : 500
3627
+ res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
3628
+ }
3629
+ return
3630
+ }
3436
3631
  if (req.method === 'GET' && pathname === '/config/gateways') {
3437
3632
  res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(await describeGateways()))
3438
3633
  return
@@ -3580,12 +3775,17 @@ const server = createServer(async (req, res) => {
3580
3775
  return
3581
3776
  }
3582
3777
  if (notifierMatch && req.method === 'DELETE') {
3583
- handleNotifierDelete(res, decodeURIComponent(notifierMatch[1]))
3778
+ handleNotifierDelete(res, decodeURIComponent(notifierMatch[1]), new URL(req.url ?? '/', 'http://localhost').searchParams.get('channel') ?? undefined)
3584
3779
  return
3585
3780
  }
3586
3781
  const notifierTestMatch = req.method === 'POST' && pathname.match(/^\/agents\/([^/]+)\/notifier\/test$/)
3587
3782
  if (notifierTestMatch) {
3588
- await handleNotifierTest(res, decodeURIComponent(notifierTestMatch[1]))
3783
+ await handleNotifierTest(req, res, decodeURIComponent(notifierTestMatch[1]))
3784
+ return
3785
+ }
3786
+ const notifierPrimaryMatch = req.method === 'PUT' && pathname.match(/^\/agents\/([^/]+)\/notifier\/primary$/)
3787
+ if (notifierPrimaryMatch) {
3788
+ await handleNotifierPrimaryPut(req, res, decodeURIComponent(notifierPrimaryMatch[1]))
3589
3789
  return
3590
3790
  }
3591
3791
  // Backs the console's Schedules tab — see handleSchedulesGet above.
@@ -3655,6 +3855,105 @@ const server = createServer(async (req, res) => {
3655
3855
  // itself need to be excluded from matching as an agent name (it
3656
3856
  // never would be one in practice, but this ordering means never
3657
3857
  // having to think about it).
3858
+ // Private ability sources (Settings) and what they offer (the
3859
+ // Abilities page's Private section): web/ability-sources-admin.ts.
3860
+ // Saving or removing one stores a token, so it's refused without auth,
3861
+ // like a model key.
3862
+ if (pathname === '/config/ability-sources' && req.method === 'GET') {
3863
+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(JSON.stringify({ ...describeAbilitySources(), canSave: secretsProtected }))
3864
+ return
3865
+ }
3866
+ const abilitySourceMatch = pathname.match(/^\/config\/ability-sources(?:\/([a-z0-9-]+))?$/)
3867
+ if (abilitySourceMatch && (req.method === 'PUT' || req.method === 'DELETE')) {
3868
+ if (!secretsProtected) {
3869
+ 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.' }))
3870
+ return
3871
+ }
3872
+ try {
3873
+ if (req.method === 'PUT') await saveAbilitySourceFromConsole(await readJsonBody(req))
3874
+ else if (abilitySourceMatch[1]) await removeAbilitySourceFromConsole(abilitySourceMatch[1])
3875
+ else throw new AbilitySourceError('Name the source to remove')
3876
+ } catch (err) {
3877
+ const status = err instanceof AbilitySourceError ? 400 : 500
3878
+ res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
3879
+ return
3880
+ }
3881
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ...describeAbilitySources(), canSave: secretsProtected }))
3882
+ return
3883
+ }
3884
+ // What an ability can do: its tools (what each does, its permission)
3885
+ // and skills. `agent` adds that agent's current decisions.
3886
+ if (pathname === '/abilities/details' && req.method === 'GET') {
3887
+ const params = new URL(req.url ?? '/', 'http://localhost').searchParams
3888
+ const spec = params.get('spec')?.trim()
3889
+ const agent = params.get('agent') ?? undefined
3890
+ if (!spec || (agent && !getEntry(agent))) {
3891
+ res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: spec ? `unknown agent '${agent}'` : 'spec is required' }))
3892
+ return
3893
+ }
3894
+ try {
3895
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(await abilityDetails(spec, { agent })))
3896
+ } catch (err) {
3897
+ res.writeHead(502, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
3898
+ }
3899
+ return
3900
+ }
3901
+ // The workspace's private abilities (core/workspace-abilities.ts):
3902
+ // installed from the Abilities page, added to agents from their tab.
3903
+ if (pathname === '/abilities/workspace' && req.method === 'GET') {
3904
+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(JSON.stringify({ abilities: listWorkspaceAbilities() }))
3905
+ return
3906
+ }
3907
+ if (pathname === '/abilities/workspace' && (req.method === 'POST' || req.method === 'DELETE')) {
3908
+ try {
3909
+ if (req.method === 'POST') {
3910
+ const spec = (await readJsonBody(req)).spec
3911
+ if (typeof spec !== 'string' || !spec.trim()) throw new WorkspaceAbilityError('spec is required')
3912
+ await installWorkspaceAbility(spec.trim())
3913
+ } else {
3914
+ removeWorkspaceAbility(new URL(req.url ?? '/', 'http://localhost').searchParams.get('name') ?? '')
3915
+ }
3916
+ } catch (err) {
3917
+ const status = err instanceof WorkspaceAbilityError ? 400 : 502
3918
+ res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
3919
+ return
3920
+ }
3921
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ abilities: listWorkspaceAbilities() }))
3922
+ return
3923
+ }
3924
+ // An ability uploaded from the browser (a .zip/.tgz, or a folder's
3925
+ // files, base64), stored in the workspace like one from a source.
3926
+ if (pathname === '/abilities/workspace/upload' && req.method === 'POST') {
3927
+ try {
3928
+ // Base64 adds a third, plus the JSON around it.
3929
+ const body = await readLimitedJsonBody(req, Math.ceil(MAX_UPLOAD_BYTES * 1.4) + 64 * 1024)
3930
+ const upload: AbilityUpload =
3931
+ body.kind === 'files' && Array.isArray(body.files)
3932
+ ? { 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 ?? '') })) }
3933
+ : { kind: 'archive', fileName: String(body.fileName ?? ''), data: String(body.data ?? '') }
3934
+ await installUploadedAbility(upload)
3935
+ } catch (err) {
3936
+ const status = err instanceof WorkspaceAbilityError ? 400 : 500
3937
+ res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
3938
+ return
3939
+ }
3940
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ abilities: listWorkspaceAbilities() }))
3941
+ return
3942
+ }
3943
+ // Public abilities on npm, for the Abilities page (no agent involved).
3944
+ if (pathname === '/abilities/search' && req.method === 'GET') {
3945
+ try {
3946
+ const results = await searchPublicAbilities(new URL(req.url ?? '/', 'http://localhost').searchParams.get('q') ?? undefined)
3947
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ results }))
3948
+ } catch (err) {
3949
+ res.writeHead(502, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
3950
+ }
3951
+ return
3952
+ }
3953
+ if (pathname === '/abilities/private' && req.method === 'GET') {
3954
+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(JSON.stringify(await listPrivateAbilities()))
3955
+ return
3956
+ }
3658
3957
  const abilitiesSearchMatch = pathname.match(/^\/agents\/([^/]+)\/abilities\/search$/)
3659
3958
  if (abilitiesSearchMatch && req.method === 'GET') {
3660
3959
  const query = new URL(req.url ?? '/', 'http://localhost').searchParams.get('q')
@@ -3701,13 +4000,19 @@ const server = createServer(async (req, res) => {
3701
4000
  return
3702
4001
  }
3703
4002
 
4003
+ const actauthToolMatch = req.method === 'PUT' && pathname.match(/^\/agents\/([^/]+)\/actauth\/tools\/([^/]+)$/)
4004
+ if (actauthToolMatch) {
4005
+ await handleActauthToolPut(req, res, decodeURIComponent(actauthToolMatch[1]), decodeURIComponent(actauthToolMatch[2]))
4006
+ return
4007
+ }
4008
+
3704
4009
  const actauthRuleMatch = pathname.match(/^\/agents\/([^/]+)\/actauth\/rules\/([^/]+)$/)
3705
4010
  if (actauthRuleMatch && req.method === 'PUT') {
3706
4011
  await handleActauthRulePut(req, res, decodeURIComponent(actauthRuleMatch[1]), decodeURIComponent(actauthRuleMatch[2]))
3707
4012
  return
3708
4013
  }
3709
4014
  if (actauthRuleMatch && req.method === 'DELETE') {
3710
- handleActauthRuleDelete(res, decodeURIComponent(actauthRuleMatch[1]), decodeURIComponent(actauthRuleMatch[2]))
4015
+ await handleActauthRuleDelete(res, decodeURIComponent(actauthRuleMatch[1]), decodeURIComponent(actauthRuleMatch[2]))
3711
4016
  return
3712
4017
  }
3713
4018
 
@@ -3719,7 +4024,7 @@ const server = createServer(async (req, res) => {
3719
4024
 
3720
4025
  const actauthMatch = req.method === 'GET' && pathname.match(/^\/agents\/([^/]+)\/actauth$/)
3721
4026
  if (actauthMatch) {
3722
- handleActauthGet(res, decodeURIComponent(actauthMatch[1]))
4027
+ await handleActauthGet(res, decodeURIComponent(actauthMatch[1]))
3723
4028
  return
3724
4029
  }
3725
4030
 
@@ -9,7 +9,7 @@
9
9
  "cli": "tsx adapters/cli.ts"
10
10
  },
11
11
  "dependencies": {
12
- "loopengine": "^0.1.93",
12
+ "loopengine": "^0.1.96",
13
13
  "actauth": "^0.0.12",
14
14
  "tsx": "^4.19.0",
15
15
  "typescript": "^5.7.0"