create-loopengine 0.1.50 → 0.1.52

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.50",
3
+ "version": "0.1.52",
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",
@@ -1,6 +1,7 @@
1
1
  node_modules
2
2
  dist
3
3
  .sessions
4
+ .checkpoints
4
5
  .env
5
6
  *.log
6
7
  .DS_Store
@@ -100,6 +100,12 @@ import {
100
100
  agentDirFor,
101
101
  verifyProxyIdentity,
102
102
  TRUSTED_PROXY_IDENTITY_HEADER,
103
+ NotifierFileError,
104
+ describeAgentNotifier,
105
+ listAgentPending,
106
+ removeAgentNotifier,
107
+ saveAgentNotifier,
108
+ testAgentNotifier,
103
109
  EnvVarNameError,
104
110
  readActauthConfig,
105
111
  addActauthRule,
@@ -178,6 +184,11 @@ const defaultHttpWebhookNotifier =
178
184
  defaultHttpWebhookUrl && defaultHttpWebhookSecret
179
185
  ? new WebhookNotifier({ webhookUrl: defaultHttpWebhookUrl, signingSecret: defaultHttpWebhookSecret })
180
186
  : undefined
187
+ // The same default, as plain settings, for the console's Approvals
188
+ // section (web/notifier-admin.ts) to show and test.
189
+ const defaultWebhook =
190
+ defaultHttpWebhookUrl && defaultHttpWebhookSecret ? { webhookUrl: defaultHttpWebhookUrl, webhookSecret: defaultHttpWebhookSecret } : undefined
191
+
181
192
 
182
193
  // Used when an AgentConfig doesn't define its own sessionIdFor — a plain
183
194
  // client-supplied key, same shape adapters/cli.ts's --session flag
@@ -382,6 +393,10 @@ async function describeAgent(entry: RegistryEntry): Promise<Record<string, unkno
382
393
  contextBudgetTokens: config.contextBudgetTokens ?? 100000,
383
394
  skillIndexBudgetTokens: config.skillIndexBudgetTokens ?? 2000,
384
395
  skillsDirs: skillsDirs,
396
+ // How many abilities are installed — just the count, for the console's
397
+ // tab badge; the Abilities tab's own GET .../abilities has the detail
398
+ // (and its slower npm version lookups).
399
+ abilityCount: listInstalledAbilities(config.name).length,
385
400
  skills: skillIndex.map((s) => ({ name: s.name, description: s.description })),
386
401
  systemSkills: systemSkillIndex.map((s) => ({ name: s.name, description: s.description })),
387
402
  tools: tools.map(describeTool),
@@ -1030,6 +1045,76 @@ function handleEnvGet(res: ServerResponse, agentName: string): void {
1030
1045
  res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(listDeclaredEnvVars(agentName)))
1031
1046
  }
1032
1047
 
1048
+ // Backs the console's Permissions → Approvals section — see
1049
+ // web/notifier-admin.ts. Reading is open like the rest of the admin
1050
+ // surface; saving or removing writes a secret and a file, so it gets the
1051
+ // same refuse-without-auth gate as the env routes below.
1052
+ function handleNotifierGet(res: ServerResponse, agentName: string): void {
1053
+ const entry = getEntry(agentName)
1054
+ if (!entry) {
1055
+ res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
1056
+ return
1057
+ }
1058
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(describeAgentNotifier(entry.config, defaultWebhook)))
1059
+ }
1060
+
1061
+ async function handleNotifierPut(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
1062
+ if (!secretsProtected) {
1063
+ res.writeHead(403, { 'content-type': 'application/json' }).end(
1064
+ JSON.stringify({ error: 'Refusing to save a notifier without LOOPENGINE_ADMIN_AUTH configured — set it first, since this stores a secret.' }),
1065
+ )
1066
+ return
1067
+ }
1068
+ const entry = getEntry(agentName)
1069
+ if (!entry) {
1070
+ res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
1071
+ return
1072
+ }
1073
+ try {
1074
+ await saveAgentNotifier(entry.config, await readJsonBody(req))
1075
+ } catch (err) {
1076
+ const status = err instanceof NotifierFileError ? 400 : 500
1077
+ res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
1078
+ return
1079
+ }
1080
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(describeAgentNotifier(entry.config, defaultWebhook)))
1081
+ }
1082
+
1083
+ function handleNotifierDelete(res: ServerResponse, agentName: string): void {
1084
+ if (!secretsProtected) {
1085
+ res.writeHead(403, { 'content-type': 'application/json' }).end(
1086
+ JSON.stringify({ error: 'Refusing to remove a notifier without LOOPENGINE_ADMIN_AUTH configured.' }),
1087
+ )
1088
+ return
1089
+ }
1090
+ const entry = getEntry(agentName)
1091
+ if (!entry) {
1092
+ res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
1093
+ return
1094
+ }
1095
+ removeAgentNotifier(entry.config)
1096
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(describeAgentNotifier(entry.config, defaultWebhook)))
1097
+ }
1098
+
1099
+ async function handleNotifierTest(res: ServerResponse, agentName: string): Promise<void> {
1100
+ const entry = getEntry(agentName)
1101
+ if (!entry) {
1102
+ res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
1103
+ return
1104
+ }
1105
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(await testAgentNotifier(entry.config, defaultWebhook)))
1106
+ }
1107
+
1108
+ // Every approval/question still waiting for this agent — for an app that
1109
+ // can't receive webhooks (it polls this instead) and for the console.
1110
+ async function handlePendingGet(res: ServerResponse, agentName: string): Promise<void> {
1111
+ if (!getEntry(agentName)) {
1112
+ res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
1113
+ return
1114
+ }
1115
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ pending: await listAgentPending(checkpoints, agentName) }))
1116
+ }
1117
+
1033
1118
  // `?slot=agent` targets this agent's own agents/<name>/.env (a per-agent
1034
1119
  // value, read by its tools through ToolContext.env) instead of the
1035
1120
  // project-wide .env — see loopengine's DeclaredEnvVar.slot.
@@ -1481,7 +1566,25 @@ async function handleSessionGet(req: IncomingMessage, res: ServerResponse, agent
1481
1566
  }
1482
1567
 
1483
1568
  type PendingSignal = { type: 'question'; entry: PendingQuestion } | { type: 'approval'; entry: PendingApproval }
1484
- type FinishedTurn = { text: string; stopReason?: 'max_turns' | 'denied' | 'pending_approval' | 'pending_question' }
1569
+ type FinishedTurn = { text: string; stopReason?: 'max_turns' | 'denied' | 'pending_approval' | 'pending_question'; pending?: PendingSummary[] }
1570
+
1571
+ // What a caller needs to act on a turn that paused for a durable
1572
+ // decision: which decision, and where to send it — the same pendingId and
1573
+ // path the notifier's webhook carries, so an app can resolve straight
1574
+ // from this response instead of waiting for the webhook or polling.
1575
+ type PendingSummary = { pendingId: string; kind: 'approval' | 'question'; tool: string; args: Record<string, unknown>; reason: string; resolvePath?: string; answerPath?: string }
1576
+
1577
+ function pendingSummary(pending: RunAgentResult['pending']): PendingSummary[] | undefined {
1578
+ if (!pending?.outstanding.length) return undefined
1579
+ return pending.outstanding.map((item) => ({
1580
+ pendingId: item.pendingId,
1581
+ kind: item.kind,
1582
+ tool: item.tool,
1583
+ args: item.args,
1584
+ reason: item.reason,
1585
+ ...(item.kind === 'question' ? { answerPath: `/pending-questions/${item.pendingId}/answer` } : { resolvePath: `/pending-approvals/${item.pendingId}/resolve` }),
1586
+ }))
1587
+ }
1485
1588
 
1486
1589
  // One entry per turn currently in flight for the plain (non-streaming)
1487
1590
  // /messages route, keyed by "<agentName>:<rawSessionId>" — the same
@@ -1617,6 +1720,7 @@ async function raceAndRespond(res: ServerResponse, agentName: string, rawSession
1617
1720
  // final-text summary of it.
1618
1721
  const responseBody: Record<string, unknown> = { text: winner.turnResult.text, sessionId: rawSessionId, events: state.events }
1619
1722
  if (winner.turnResult.stopReason) responseBody.stopReason = winner.turnResult.stopReason
1723
+ if (winner.turnResult.pending) responseBody.pending = winner.turnResult.pending
1620
1724
  res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(responseBody))
1621
1725
  }
1622
1726
 
@@ -1695,7 +1799,7 @@ async function handleMessages(req: IncomingMessage, res: ServerResponse, agentNa
1695
1799
  // raceAndRespond) always ends the same way regardless of which route
1696
1800
  // produced it.
1697
1801
  state.events.push({ type: 'done', text: result.text, ...(result.stopReason ? { stopReason: result.stopReason } : {}) })
1698
- return { newMessages: result.newMessages, result: { text: result.text, stopReason: result.stopReason } }
1802
+ return { newMessages: result.newMessages, result: { text: result.text, stopReason: result.stopReason, pending: pendingSummary(result.pending) } }
1699
1803
  })
1700
1804
  state.turnPromise = turnPromise
1701
1805
 
@@ -1801,7 +1905,7 @@ async function handleMessagesStream(req: IncomingMessage, res: ServerResponse, a
1801
1905
  const doneEvent: LoopEvent = { type: 'done', text: result.text, ...(result.stopReason ? { stopReason: result.stopReason } : {}) }
1802
1906
  writeSseEvent(res, doneEvent)
1803
1907
  state.events.push(doneEvent)
1804
- return { newMessages: result.newMessages, result: { text: result.text, stopReason: result.stopReason } }
1908
+ return { newMessages: result.newMessages, result: { text: result.text, stopReason: result.stopReason, pending: pendingSummary(result.pending) } }
1805
1909
  })
1806
1910
  state.turnPromise = turnPromise
1807
1911
 
@@ -2143,11 +2247,11 @@ async function respondAfterResolution(res: ServerResponse, finalCheckpoint: Turn
2143
2247
  if (result.pending) {
2144
2248
  await createCheckpointFromPending(finalCheckpoint.agent, finalCheckpoint.tenant, finalCheckpoint.sessionId, result.pending)
2145
2249
  }
2146
- return { newMessages: result.newMessages, result: { text: result.text, stopReason: result.stopReason } }
2250
+ return { newMessages: result.newMessages, result: { text: result.text, stopReason: result.stopReason, pending: pendingSummary(result.pending) } }
2147
2251
  })
2148
2252
 
2149
2253
  res.writeHead(200, { 'content-type': 'application/json' }).end(
2150
- JSON.stringify({ text: responseBody.text, sessionId: finalCheckpoint.sessionId, ...(responseBody.stopReason ? { stopReason: responseBody.stopReason } : {}) }),
2254
+ JSON.stringify({ text: responseBody.text, sessionId: finalCheckpoint.sessionId, ...(responseBody.stopReason ? { stopReason: responseBody.stopReason } : {}), ...(responseBody.pending ? { pending: responseBody.pending } : {}) }),
2151
2255
  )
2152
2256
  }
2153
2257
 
@@ -2709,6 +2813,32 @@ const server = createServer(async (req, res) => {
2709
2813
  return
2710
2814
  }
2711
2815
 
2816
+ // Backs the console's Approvals section and polling clients — see
2817
+ // handleNotifierGet/handlePendingGet above.
2818
+ const notifierMatch = pathname.match(/^\/agents\/([^/]+)\/notifier$/)
2819
+ if (notifierMatch && req.method === 'GET') {
2820
+ handleNotifierGet(res, decodeURIComponent(notifierMatch[1]))
2821
+ return
2822
+ }
2823
+ if (notifierMatch && req.method === 'PUT') {
2824
+ await handleNotifierPut(req, res, decodeURIComponent(notifierMatch[1]))
2825
+ return
2826
+ }
2827
+ if (notifierMatch && req.method === 'DELETE') {
2828
+ handleNotifierDelete(res, decodeURIComponent(notifierMatch[1]))
2829
+ return
2830
+ }
2831
+ const notifierTestMatch = req.method === 'POST' && pathname.match(/^\/agents\/([^/]+)\/notifier\/test$/)
2832
+ if (notifierTestMatch) {
2833
+ await handleNotifierTest(res, decodeURIComponent(notifierTestMatch[1]))
2834
+ return
2835
+ }
2836
+ const pendingListMatch = req.method === 'GET' && pathname.match(/^\/agents\/([^/]+)\/pending$/)
2837
+ if (pendingListMatch) {
2838
+ await handlePendingGet(res, decodeURIComponent(pendingListMatch[1]))
2839
+ return
2840
+ }
2841
+
2712
2842
  // Backs the Admin UI's "Environment" section — see
2713
2843
  // handleEnvGet/handleEnvPut's own doc comments above.
2714
2844
  const envListMatch = pathname.match(/^\/agents\/([^/]+)\/env$/)
@@ -9,7 +9,7 @@
9
9
  "cli": "tsx adapters/cli.ts"
10
10
  },
11
11
  "dependencies": {
12
- "loopengine": "^0.1.63",
12
+ "loopengine": "^0.1.67",
13
13
  "actauth": "^0.0.12"
14
14
  },
15
15
  "devDependencies": {