create-loopengine 0.1.51 → 0.1.53

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.51",
3
+ "version": "0.1.53",
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,16 @@ import {
100
100
  agentDirFor,
101
101
  verifyProxyIdentity,
102
102
  TRUSTED_PROXY_IDENTITY_HEADER,
103
+ NotifierFileError,
104
+ verifyDecisionToken,
105
+ renderDecidePage,
106
+ renderDecideGonePage,
107
+ renderDecideInvalidPage,
108
+ describeAgentNotifier,
109
+ listAgentPending,
110
+ removeAgentNotifier,
111
+ saveAgentNotifier,
112
+ testAgentNotifier,
103
113
  EnvVarNameError,
104
114
  readActauthConfig,
105
115
  addActauthRule,
@@ -178,6 +188,11 @@ const defaultHttpWebhookNotifier =
178
188
  defaultHttpWebhookUrl && defaultHttpWebhookSecret
179
189
  ? new WebhookNotifier({ webhookUrl: defaultHttpWebhookUrl, signingSecret: defaultHttpWebhookSecret })
180
190
  : undefined
191
+ // The same default, as plain settings, for the console's Approvals
192
+ // section (web/notifier-admin.ts) to show and test.
193
+ const defaultWebhook =
194
+ defaultHttpWebhookUrl && defaultHttpWebhookSecret ? { webhookUrl: defaultHttpWebhookUrl, webhookSecret: defaultHttpWebhookSecret } : undefined
195
+
181
196
 
182
197
  // Used when an AgentConfig doesn't define its own sessionIdFor — a plain
183
198
  // client-supplied key, same shape adapters/cli.ts's --session flag
@@ -1034,6 +1049,76 @@ function handleEnvGet(res: ServerResponse, agentName: string): void {
1034
1049
  res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(listDeclaredEnvVars(agentName)))
1035
1050
  }
1036
1051
 
1052
+ // Backs the console's Permissions tab (where approval requests and questions are sent) — see
1053
+ // web/notifier-admin.ts. Reading is open like the rest of the admin
1054
+ // surface; saving or removing writes a secret and a file, so it gets the
1055
+ // same refuse-without-auth gate as the env routes below.
1056
+ function handleNotifierGet(res: ServerResponse, agentName: string): void {
1057
+ const entry = getEntry(agentName)
1058
+ if (!entry) {
1059
+ res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
1060
+ return
1061
+ }
1062
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ...describeAgentNotifier(entry.config, defaultWebhook), canSave: secretsProtected }))
1063
+ }
1064
+
1065
+ async function handleNotifierPut(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
1066
+ if (!secretsProtected) {
1067
+ res.writeHead(403, { 'content-type': 'application/json' }).end(
1068
+ JSON.stringify({ error: 'Refusing to save a notifier without LOOPENGINE_ADMIN_AUTH configured — set it first, since this stores a secret.' }),
1069
+ )
1070
+ return
1071
+ }
1072
+ const entry = getEntry(agentName)
1073
+ if (!entry) {
1074
+ res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
1075
+ return
1076
+ }
1077
+ try {
1078
+ await saveAgentNotifier(entry.config, await readJsonBody(req))
1079
+ } catch (err) {
1080
+ const status = err instanceof NotifierFileError ? 400 : 500
1081
+ res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
1082
+ return
1083
+ }
1084
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ...describeAgentNotifier(entry.config, defaultWebhook), canSave: secretsProtected }))
1085
+ }
1086
+
1087
+ function handleNotifierDelete(res: ServerResponse, agentName: string): void {
1088
+ if (!secretsProtected) {
1089
+ res.writeHead(403, { 'content-type': 'application/json' }).end(
1090
+ JSON.stringify({ error: 'Refusing to remove a notifier without LOOPENGINE_ADMIN_AUTH configured.' }),
1091
+ )
1092
+ return
1093
+ }
1094
+ const entry = getEntry(agentName)
1095
+ if (!entry) {
1096
+ res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
1097
+ return
1098
+ }
1099
+ removeAgentNotifier(entry.config)
1100
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ...describeAgentNotifier(entry.config, defaultWebhook), canSave: secretsProtected }))
1101
+ }
1102
+
1103
+ async function handleNotifierTest(res: ServerResponse, agentName: string): Promise<void> {
1104
+ const entry = getEntry(agentName)
1105
+ if (!entry) {
1106
+ res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
1107
+ return
1108
+ }
1109
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(await testAgentNotifier(entry.config, defaultWebhook)))
1110
+ }
1111
+
1112
+ // Every approval/question still waiting for this agent — for an app that
1113
+ // can't receive webhooks (it polls this instead) and for the console.
1114
+ async function handlePendingGet(res: ServerResponse, agentName: string): Promise<void> {
1115
+ if (!getEntry(agentName)) {
1116
+ res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
1117
+ return
1118
+ }
1119
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ pending: await listAgentPending(checkpoints, agentName) }))
1120
+ }
1121
+
1037
1122
  // `?slot=agent` targets this agent's own agents/<name>/.env (a per-agent
1038
1123
  // value, read by its tools through ToolContext.env) instead of the
1039
1124
  // project-wide .env — see loopengine's DeclaredEnvVar.slot.
@@ -1485,7 +1570,25 @@ async function handleSessionGet(req: IncomingMessage, res: ServerResponse, agent
1485
1570
  }
1486
1571
 
1487
1572
  type PendingSignal = { type: 'question'; entry: PendingQuestion } | { type: 'approval'; entry: PendingApproval }
1488
- type FinishedTurn = { text: string; stopReason?: 'max_turns' | 'denied' | 'pending_approval' | 'pending_question' }
1573
+ type FinishedTurn = { text: string; stopReason?: 'max_turns' | 'denied' | 'pending_approval' | 'pending_question'; pending?: PendingSummary[] }
1574
+
1575
+ // What a caller needs to act on a turn that paused for a durable
1576
+ // decision: which decision, and where to send it — the same pendingId and
1577
+ // path the notifier's webhook carries, so an app can resolve straight
1578
+ // from this response instead of waiting for the webhook or polling.
1579
+ type PendingSummary = { pendingId: string; kind: 'approval' | 'question'; tool: string; args: Record<string, unknown>; reason: string; resolvePath?: string; answerPath?: string }
1580
+
1581
+ function pendingSummary(pending: RunAgentResult['pending']): PendingSummary[] | undefined {
1582
+ if (!pending?.outstanding.length) return undefined
1583
+ return pending.outstanding.map((item) => ({
1584
+ pendingId: item.pendingId,
1585
+ kind: item.kind,
1586
+ tool: item.tool,
1587
+ args: item.args,
1588
+ reason: item.reason,
1589
+ ...(item.kind === 'question' ? { answerPath: `/pending-questions/${item.pendingId}/answer` } : { resolvePath: `/pending-approvals/${item.pendingId}/resolve` }),
1590
+ }))
1591
+ }
1489
1592
 
1490
1593
  // One entry per turn currently in flight for the plain (non-streaming)
1491
1594
  // /messages route, keyed by "<agentName>:<rawSessionId>" — the same
@@ -1621,6 +1724,7 @@ async function raceAndRespond(res: ServerResponse, agentName: string, rawSession
1621
1724
  // final-text summary of it.
1622
1725
  const responseBody: Record<string, unknown> = { text: winner.turnResult.text, sessionId: rawSessionId, events: state.events }
1623
1726
  if (winner.turnResult.stopReason) responseBody.stopReason = winner.turnResult.stopReason
1727
+ if (winner.turnResult.pending) responseBody.pending = winner.turnResult.pending
1624
1728
  res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(responseBody))
1625
1729
  }
1626
1730
 
@@ -1699,7 +1803,7 @@ async function handleMessages(req: IncomingMessage, res: ServerResponse, agentNa
1699
1803
  // raceAndRespond) always ends the same way regardless of which route
1700
1804
  // produced it.
1701
1805
  state.events.push({ type: 'done', text: result.text, ...(result.stopReason ? { stopReason: result.stopReason } : {}) })
1702
- return { newMessages: result.newMessages, result: { text: result.text, stopReason: result.stopReason } }
1806
+ return { newMessages: result.newMessages, result: { text: result.text, stopReason: result.stopReason, pending: pendingSummary(result.pending) } }
1703
1807
  })
1704
1808
  state.turnPromise = turnPromise
1705
1809
 
@@ -1805,7 +1909,7 @@ async function handleMessagesStream(req: IncomingMessage, res: ServerResponse, a
1805
1909
  const doneEvent: LoopEvent = { type: 'done', text: result.text, ...(result.stopReason ? { stopReason: result.stopReason } : {}) }
1806
1910
  writeSseEvent(res, doneEvent)
1807
1911
  state.events.push(doneEvent)
1808
- return { newMessages: result.newMessages, result: { text: result.text, stopReason: result.stopReason } }
1912
+ return { newMessages: result.newMessages, result: { text: result.text, stopReason: result.stopReason, pending: pendingSummary(result.pending) } }
1809
1913
  })
1810
1914
  state.turnPromise = turnPromise
1811
1915
 
@@ -1939,7 +2043,11 @@ async function createCheckpointFromPending(
1939
2043
  * response here is authoritative on its own, not a fallback for some
1940
2044
  * other channel. */
1941
2045
  async function handlePendingApprovalResolve(req: IncomingMessage, res: ServerResponse, pendingId: string): Promise<void> {
1942
- const body = await readJsonBody(req)
2046
+ await resolvePendingApproval(res, pendingId, await readJsonBody(req))
2047
+ }
2048
+
2049
+ // The resolve itself, shared with the decision page's POST /decide/<token>.
2050
+ async function resolvePendingApproval(res: ServerResponse, pendingId: string, body: Record<string, unknown>): Promise<void> {
1943
2051
  const decision = body.decision === 'approve' || body.decision === 'deny' ? body.decision : undefined
1944
2052
  if (!decision) {
1945
2053
  res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: "decision must be 'approve' or 'deny'" }))
@@ -2051,7 +2159,10 @@ async function handlePendingApprovalResolve(req: IncomingMessage, res: ServerRes
2051
2159
  * either answered or it isn't. Shares the same "outstanding empty →
2052
2160
  * resume, else report remaining count" tail via respondAfterResolution. */
2053
2161
  async function handlePendingQuestionAnswer(req: IncomingMessage, res: ServerResponse, pendingId: string): Promise<void> {
2054
- const body = await readJsonBody(req)
2162
+ await answerPendingQuestion(res, pendingId, await readJsonBody(req))
2163
+ }
2164
+
2165
+ async function answerPendingQuestion(res: ServerResponse, pendingId: string, body: Record<string, unknown>): Promise<void> {
2055
2166
  if (typeof body.answer !== 'string') {
2056
2167
  res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: "'answer' must be a string" }))
2057
2168
  return
@@ -2147,11 +2258,11 @@ async function respondAfterResolution(res: ServerResponse, finalCheckpoint: Turn
2147
2258
  if (result.pending) {
2148
2259
  await createCheckpointFromPending(finalCheckpoint.agent, finalCheckpoint.tenant, finalCheckpoint.sessionId, result.pending)
2149
2260
  }
2150
- return { newMessages: result.newMessages, result: { text: result.text, stopReason: result.stopReason } }
2261
+ return { newMessages: result.newMessages, result: { text: result.text, stopReason: result.stopReason, pending: pendingSummary(result.pending) } }
2151
2262
  })
2152
2263
 
2153
2264
  res.writeHead(200, { 'content-type': 'application/json' }).end(
2154
- JSON.stringify({ text: responseBody.text, sessionId: finalCheckpoint.sessionId, ...(responseBody.stopReason ? { stopReason: responseBody.stopReason } : {}) }),
2265
+ JSON.stringify({ text: responseBody.text, sessionId: finalCheckpoint.sessionId, ...(responseBody.stopReason ? { stopReason: responseBody.stopReason } : {}), ...(responseBody.pending ? { pending: responseBody.pending } : {}) }),
2155
2266
  )
2156
2267
  }
2157
2268
 
@@ -2337,7 +2448,59 @@ function handleActivityGet(res: ServerResponse): void {
2337
2448
  )
2338
2449
  }
2339
2450
 
2451
+ // A decision link (loopengine's core/approval-links.ts): GET shows the decision page,
2452
+ // POST makes the decision. The signed token is the credential, so these
2453
+ // sit outside the admin login on purpose — the link is meant for
2454
+ // whoever gets the notification, not only admins.
2455
+ async function handleDecide(req: IncomingMessage, res: ServerResponse, rawToken: string): Promise<void> {
2456
+ const html = { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' }
2457
+ const json = { 'content-type': 'application/json', 'cache-control': 'no-store' }
2458
+ const token = verifyDecisionToken(rawToken)
2459
+ if (!token) {
2460
+ if (req.method === 'GET') res.writeHead(404, html).end(renderDecideInvalidPage())
2461
+ else res.writeHead(400, json).end(JSON.stringify({ error: 'This link is not valid or has expired.' }))
2462
+ return
2463
+ }
2464
+ const checkpoint = await checkpoints.get(token.pendingId)
2465
+ const item = checkpoint?.outstanding[token.pendingId]
2466
+ if (!checkpoint || !item || (item.kind ?? 'approval') !== token.kind) {
2467
+ if (req.method === 'GET') res.writeHead(410, html).end(renderDecideGonePage())
2468
+ else res.writeHead(410, json).end(JSON.stringify({ error: 'This decision has already been made.' }))
2469
+ return
2470
+ }
2471
+ if (req.method === 'GET') {
2472
+ res.writeHead(200, html).end(
2473
+ renderDecidePage({
2474
+ kind: token.kind,
2475
+ agent: checkpoint.agent,
2476
+ tool: item.tool,
2477
+ args: item.args,
2478
+ reason: item.reason,
2479
+ sessionId: checkpoint.sessionId,
2480
+ tenant: checkpoint.tenant,
2481
+ createdAt: checkpoint.createdAt,
2482
+ expiresAt: token.expiresAt,
2483
+ }),
2484
+ )
2485
+ return
2486
+ }
2487
+ const body = await readJsonBody(req)
2488
+ if (token.kind === 'question') await answerPendingQuestion(res, token.pendingId, { answer: body.answer })
2489
+ else await resolvePendingApproval(res, token.pendingId, { decision: body.decision, editedArgs: body.editedArgs })
2490
+ }
2491
+
2340
2492
  const server = createServer(async (req, res) => {
2493
+ const decideMatch = (req.method === 'GET' || req.method === 'POST') && (req.url ?? '/').split('?')[0].match(/^\/decide\/([A-Za-z0-9_.-]+)$/)
2494
+ if (decideMatch) {
2495
+ try {
2496
+ await handleDecide(req, res, decideMatch[1])
2497
+ } catch (err) {
2498
+ console.error('[loopengine] decision link failed:', err)
2499
+ if (!res.headersSent) res.writeHead(500, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'Something went wrong; try again.' }))
2500
+ }
2501
+ return
2502
+ }
2503
+
2341
2504
  if (!isAuthorized(req)) {
2342
2505
  const headers: Record<string, string> = { 'content-type': 'application/json' }
2343
2506
  if (adminAuth) headers['www-authenticate'] = 'Basic realm="loopengine"'
@@ -2713,6 +2876,32 @@ const server = createServer(async (req, res) => {
2713
2876
  return
2714
2877
  }
2715
2878
 
2879
+ // Backs the console's Approvals section and polling clients — see
2880
+ // handleNotifierGet/handlePendingGet above.
2881
+ const notifierMatch = pathname.match(/^\/agents\/([^/]+)\/notifier$/)
2882
+ if (notifierMatch && req.method === 'GET') {
2883
+ handleNotifierGet(res, decodeURIComponent(notifierMatch[1]))
2884
+ return
2885
+ }
2886
+ if (notifierMatch && req.method === 'PUT') {
2887
+ await handleNotifierPut(req, res, decodeURIComponent(notifierMatch[1]))
2888
+ return
2889
+ }
2890
+ if (notifierMatch && req.method === 'DELETE') {
2891
+ handleNotifierDelete(res, decodeURIComponent(notifierMatch[1]))
2892
+ return
2893
+ }
2894
+ const notifierTestMatch = req.method === 'POST' && pathname.match(/^\/agents\/([^/]+)\/notifier\/test$/)
2895
+ if (notifierTestMatch) {
2896
+ await handleNotifierTest(res, decodeURIComponent(notifierTestMatch[1]))
2897
+ return
2898
+ }
2899
+ const pendingListMatch = req.method === 'GET' && pathname.match(/^\/agents\/([^/]+)\/pending$/)
2900
+ if (pendingListMatch) {
2901
+ await handlePendingGet(res, decodeURIComponent(pendingListMatch[1]))
2902
+ return
2903
+ }
2904
+
2716
2905
  // Backs the Admin UI's "Environment" section — see
2717
2906
  // handleEnvGet/handleEnvPut's own doc comments above.
2718
2907
  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.68",
13
13
  "actauth": "^0.0.12"
14
14
  },
15
15
  "devDependencies": {