create-loopengine 0.1.76 → 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.76",
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,6 +213,7 @@ import {
196
213
  UpgradeError,
197
214
  setModelProviderKey,
198
215
  clearModelProviderKey,
216
+ listProviderModels,
199
217
  ModelKeyError,
200
218
  describeTimeZone,
201
219
  setWorkspaceTimeZone,
@@ -319,6 +337,32 @@ function readRawBody(req: IncomingMessage): Promise<string> {
319
337
  })
320
338
  }
321
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
+
322
366
  function readJsonBody(req: IncomingMessage): Promise<Record<string, unknown>> {
323
367
  return new Promise((resolve, reject) => {
324
368
  let data = ''
@@ -866,7 +910,9 @@ function parseAgentTemplateOptions(body: Record<string, unknown>): { ok: true; v
866
910
  provider !== 'deepseek' &&
867
911
  provider !== 'kimi' &&
868
912
  provider !== 'glm' &&
869
- provider !== 'gemini'
913
+ provider !== 'gemini' &&
914
+ provider !== 'xai' &&
915
+ provider !== 'meta'
870
916
  ) {
871
917
  return { ok: false, error: `unsupported model provider '${String(provider)}'` }
872
918
  }
@@ -1367,7 +1413,8 @@ async function handleNotifierPut(req: IncomingMessage, res: ServerResponse, agen
1367
1413
  res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ...describeAgentNotifier(entry.config, defaultWebhook), canSave: secretsProtected }))
1368
1414
  }
1369
1415
 
1370
- 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 {
1371
1418
  if (!secretsProtected) {
1372
1419
  res.writeHead(403, { 'content-type': 'application/json' }).end(
1373
1420
  JSON.stringify({ error: 'Refusing to remove a notifier without LOOPENGINE_ADMIN_AUTH configured.' }),
@@ -1379,17 +1426,42 @@ function handleNotifierDelete(res: ServerResponse, agentName: string): void {
1379
1426
  res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
1380
1427
  return
1381
1428
  }
1382
- removeAgentNotifier(entry.config)
1429
+ removeAgentNotifier(entry.config, channel)
1383
1430
  res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ...describeAgentNotifier(entry.config, defaultWebhook), canSave: secretsProtected }))
1384
1431
  }
1385
1432
 
1386
- 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> {
1387
1436
  const entry = getEntry(agentName)
1388
1437
  if (!entry) {
1389
1438
  res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
1390
1439
  return
1391
1440
  }
1392
- res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(await testAgentNotifier(entry.config, defaultWebhook)))
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
+ }
1452
+ const entry = getEntry(agentName)
1453
+ if (!entry) {
1454
+ res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
1455
+ return
1456
+ }
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 }))
1393
1465
  }
1394
1466
 
1395
1467
  // Backs the console's Slack channel picker. A bot token typed into the form
@@ -1585,6 +1657,10 @@ async function handleAbilitiesGet(res: ServerResponse, agentName: string): Promi
1585
1657
  return
1586
1658
  }
1587
1659
  const installed = listInstalledAbilities(agentName)
1660
+ let sources: AbilitySource[] = []
1661
+ try {
1662
+ sources = readAbilitySources()
1663
+ } catch {}
1588
1664
  const abilities = await Promise.all(
1589
1665
  installed.map(async (a) => {
1590
1666
  // a.dependencies is only ever undefined for a record from before
@@ -1594,9 +1670,13 @@ async function handleAbilitiesGet(res: ServerResponse, agentName: string): Promi
1594
1670
  // otherwise permanently hide "Install deps" for an ability that
1595
1671
  // genuinely still needs something.
1596
1672
  const dependencies = a.dependencies !== undefined ? a.dependencies : ((await backfillDependencies(agentName, a.name)) ?? [])
1597
- 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
1598
1678
  const missingDependencies = dependencies.filter((dep) => !hasNodeModule(dep))
1599
- return { ...a, dependencies, latestVersion, upToDate: latestVersion !== null && latestVersion === a.version, missingDependencies }
1679
+ return { ...a, origin, dependencies, latestVersion, upToDate: latestVersion !== null && latestVersion === a.version, missingDependencies }
1600
1680
  }),
1601
1681
  )
1602
1682
  res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ abilities }))
@@ -1786,12 +1866,86 @@ function parseActauthRuleBody(body: Record<string, unknown>, requireName: boolea
1786
1866
  return { ok: true, value: { name: typeof body.name === 'string' ? body.name : '', scope: body.scope, tool: body.tool, decision: body.decision } }
1787
1867
  }
1788
1868
 
1789
- 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> {
1790
1916
  if (!getEntry(agentName)) {
1791
1917
  res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
1792
1918
  return
1793
1919
  }
1794
- 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)))
1795
1949
  }
1796
1950
 
1797
1951
  async function handleActauthRulePost(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
@@ -1812,7 +1966,7 @@ async function handleActauthRulePost(req: IncomingMessage, res: ServerResponse,
1812
1966
  res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
1813
1967
  return
1814
1968
  }
1815
- 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)))
1816
1970
  }
1817
1971
 
1818
1972
  async function handleActauthRulePut(req: IncomingMessage, res: ServerResponse, agentName: string, ruleName: string): Promise<void> {
@@ -1833,10 +1987,10 @@ async function handleActauthRulePut(req: IncomingMessage, res: ServerResponse, a
1833
1987
  res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
1834
1988
  return
1835
1989
  }
1836
- 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)))
1837
1991
  }
1838
1992
 
1839
- function handleActauthRuleDelete(res: ServerResponse, agentName: string, ruleName: string): void {
1993
+ async function handleActauthRuleDelete(res: ServerResponse, agentName: string, ruleName: string): Promise<void> {
1840
1994
  if (!getEntry(agentName)) {
1841
1995
  res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
1842
1996
  return
@@ -1848,7 +2002,7 @@ function handleActauthRuleDelete(res: ServerResponse, agentName: string, ruleNam
1848
2002
  res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
1849
2003
  return
1850
2004
  }
1851
- 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)))
1852
2006
  }
1853
2007
 
1854
2008
  async function handleActauthDefaultDecisionPut(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
@@ -1862,7 +2016,7 @@ async function handleActauthDefaultDecisionPut(req: IncomingMessage, res: Server
1862
2016
  return
1863
2017
  }
1864
2018
  setDefaultDecision(agentName, body.decision)
1865
- 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)))
1866
2020
  }
1867
2021
 
1868
2022
  // GET /agents content-negotiates on this: a browser navigating there sends
@@ -3416,6 +3570,19 @@ const server = createServer(async (req, res) => {
3416
3570
  )
3417
3571
  return
3418
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
+ }
3419
3586
  // A provider's API key, from the Models page: write-only, never read
3420
3587
  // back. Same rule as the Environment tab: refused on a server with no
3421
3588
  // auth, since anyone who could reach it could replace the key.
@@ -3608,12 +3775,17 @@ const server = createServer(async (req, res) => {
3608
3775
  return
3609
3776
  }
3610
3777
  if (notifierMatch && req.method === 'DELETE') {
3611
- handleNotifierDelete(res, decodeURIComponent(notifierMatch[1]))
3778
+ handleNotifierDelete(res, decodeURIComponent(notifierMatch[1]), new URL(req.url ?? '/', 'http://localhost').searchParams.get('channel') ?? undefined)
3612
3779
  return
3613
3780
  }
3614
3781
  const notifierTestMatch = req.method === 'POST' && pathname.match(/^\/agents\/([^/]+)\/notifier\/test$/)
3615
3782
  if (notifierTestMatch) {
3616
- 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]))
3617
3789
  return
3618
3790
  }
3619
3791
  // Backs the console's Schedules tab — see handleSchedulesGet above.
@@ -3683,6 +3855,105 @@ const server = createServer(async (req, res) => {
3683
3855
  // itself need to be excluded from matching as an agent name (it
3684
3856
  // never would be one in practice, but this ordering means never
3685
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
+ }
3686
3957
  const abilitiesSearchMatch = pathname.match(/^\/agents\/([^/]+)\/abilities\/search$/)
3687
3958
  if (abilitiesSearchMatch && req.method === 'GET') {
3688
3959
  const query = new URL(req.url ?? '/', 'http://localhost').searchParams.get('q')
@@ -3729,13 +4000,19 @@ const server = createServer(async (req, res) => {
3729
4000
  return
3730
4001
  }
3731
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
+
3732
4009
  const actauthRuleMatch = pathname.match(/^\/agents\/([^/]+)\/actauth\/rules\/([^/]+)$/)
3733
4010
  if (actauthRuleMatch && req.method === 'PUT') {
3734
4011
  await handleActauthRulePut(req, res, decodeURIComponent(actauthRuleMatch[1]), decodeURIComponent(actauthRuleMatch[2]))
3735
4012
  return
3736
4013
  }
3737
4014
  if (actauthRuleMatch && req.method === 'DELETE') {
3738
- handleActauthRuleDelete(res, decodeURIComponent(actauthRuleMatch[1]), decodeURIComponent(actauthRuleMatch[2]))
4015
+ await handleActauthRuleDelete(res, decodeURIComponent(actauthRuleMatch[1]), decodeURIComponent(actauthRuleMatch[2]))
3739
4016
  return
3740
4017
  }
3741
4018
 
@@ -3747,7 +4024,7 @@ const server = createServer(async (req, res) => {
3747
4024
 
3748
4025
  const actauthMatch = req.method === 'GET' && pathname.match(/^\/agents\/([^/]+)\/actauth$/)
3749
4026
  if (actauthMatch) {
3750
- handleActauthGet(res, decodeURIComponent(actauthMatch[1]))
4027
+ await handleActauthGet(res, decodeURIComponent(actauthMatch[1]))
3751
4028
  return
3752
4029
  }
3753
4030
 
@@ -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.96",
13
13
  "actauth": "^0.0.12",
14
14
  "tsx": "^4.19.0",
15
15
  "typescript": "^5.7.0"