create-loopengine 0.1.65 → 0.1.67

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.65",
3
+ "version": "0.1.67",
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",
@@ -125,6 +125,13 @@ import {
125
125
  listAgentSlackChannels,
126
126
  slackCredentialsFor,
127
127
  slackConversationsOn,
128
+ slackClickAllowed,
129
+ agentApprovers,
130
+ setAgentApprovers,
131
+ listAgentSlackMembers,
132
+ replyPrivatelyInSlack,
133
+ type MessagingDecision,
134
+ type DecisionOutcome,
128
135
  openSlackAnswerModal,
129
136
  parseSlackInteraction,
130
137
  replyInSlack,
@@ -1256,6 +1263,44 @@ async function handleSlackChannels(req: IncomingMessage, res: ServerResponse, ag
1256
1263
  }
1257
1264
  }
1258
1265
 
1266
+ // Messaging tab → Approvers: who can decide this agent's Slack requests
1267
+ // by replying (core/messaging/), and the people in its channel to pick
1268
+ // from. Changing the list needs the admin login, like saving a secret:
1269
+ // it decides who can approve what the agent does.
1270
+ async function handleApproversPut(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
1271
+ const entry = getEntry(agentName)
1272
+ if (!entry) {
1273
+ res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
1274
+ return
1275
+ }
1276
+ if (!secretsProtected) {
1277
+ res.writeHead(403, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'Changing approvers needs LOOPENGINE_ADMIN_AUTH set on the server.' }))
1278
+ return
1279
+ }
1280
+ const body = await readJsonBody(req)
1281
+ try {
1282
+ const approvers = setAgentApprovers(entry.config, body.approvers)
1283
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ approvers }))
1284
+ } catch (err) {
1285
+ const status = err instanceof NotifierFileError ? 400 : 500
1286
+ res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
1287
+ }
1288
+ }
1289
+
1290
+ async function handleSlackMembers(res: ServerResponse, agentName: string): Promise<void> {
1291
+ const entry = getEntry(agentName)
1292
+ if (!entry) {
1293
+ res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
1294
+ return
1295
+ }
1296
+ try {
1297
+ const members = await listAgentSlackMembers(entry.config)
1298
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ members }))
1299
+ } catch (err) {
1300
+ res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
1301
+ }
1302
+ }
1303
+
1259
1304
  // Every approval/question still waiting for this agent — for an app that
1260
1305
  // can't receive webhooks (it polls this instead) and for the console.
1261
1306
  async function handlePendingGet(res: ServerResponse, agentName: string): Promise<void> {
@@ -2709,6 +2754,17 @@ async function handleSlackInteraction(req: IncomingMessage, res: ServerResponse,
2709
2754
  return
2710
2755
  }
2711
2756
  const interaction = parseSlackInteraction(rawBody)
2757
+ // Once the agent has approvers (Messaging tab), only their clicks count.
2758
+ if (interaction.type !== 'ignore') {
2759
+ const gate = slackClickAllowed(entry.config, interaction.user)
2760
+ if (!gate.allowed) {
2761
+ res.writeHead(200).end()
2762
+ if ('responseUrl' in interaction && interaction.responseUrl) {
2763
+ await replyPrivatelyInSlack(interaction.responseUrl, gate.message ?? 'Only approvers can decide this.').catch((err) => console.error('[loopengine] Slack reply failed:', err))
2764
+ }
2765
+ return
2766
+ }
2767
+ }
2712
2768
  if (interaction.type === 'open_answer') {
2713
2769
  if (botToken) await openSlackAnswerModal(botToken, interaction.triggerId, interaction.pendingId).catch((err) => console.error('[loopengine] Slack modal failed:', err))
2714
2770
  res.writeHead(200).end()
@@ -2770,9 +2826,26 @@ async function handleSlackEvents(req: IncomingMessage, res: ServerResponse, agen
2770
2826
  getCheckpoint: (pendingId) => checkpoints.get(pendingId),
2771
2827
  getHistory: (checkpoint) => sessions.getHistory(`${checkpoint.tenant}:${environment}:${checkpoint.agent}:${checkpoint.sessionId}`),
2772
2828
  plan: process.env.LOOPENGINE_PLAN,
2829
+ approvers: () => agentApprovers(entry.config),
2830
+ decide: decideFromMessaging,
2773
2831
  })
2774
2832
  }
2775
2833
 
2834
+ // A decision made by replying in a request's thread (core/messaging/),
2835
+ // recorded through the same resolve and answer handlers as the console's
2836
+ // buttons.
2837
+ async function decideFromMessaging(pendingId: string, decision: MessagingDecision): Promise<DecisionOutcome> {
2838
+ const capture = captureResponse()
2839
+ if (decision.kind === 'answer') await answerPendingQuestion(capture.res, pendingId, { answer: decision.answer })
2840
+ else await resolvePendingApproval(capture.res, pendingId, { decision: decision.kind })
2841
+ const { status, body } = await capture.done
2842
+ if (status >= 400) return { error: typeof body.error === 'string' ? body.error : `HTTP ${status}` }
2843
+ if (body.alreadyResolved) return { alreadyResolved: true }
2844
+ if (body.resolved) return { stillWaiting: true }
2845
+ if (Array.isArray(body.pending) && body.pending.length) return { morePending: true }
2846
+ return { text: typeof body.text === 'string' ? body.text : undefined }
2847
+ }
2848
+
2776
2849
  // Set once shutdown() starts, so /healthz turns 503 while the server
2777
2850
  // drains and a router or load balancer stops sending it new requests.
2778
2851
  let shuttingDown = false
@@ -3098,7 +3171,11 @@ const server = createServer(async (req, res) => {
3098
3171
  // The Settings section's Upgrade button (web/upgrade-admin.ts).
3099
3172
  if (req.method === 'GET' && pathname === '/config/upgrade') {
3100
3173
  res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(
3101
- JSON.stringify({ ...(await describeUpgrade()), canUpgrade: secretsProtected, restartsItself: exitOnCodeChange }),
3174
+ JSON.stringify({
3175
+ ...(await describeUpgrade(undefined, { refresh: new URL(req.url ?? '/', 'http://localhost').searchParams.get('refresh') === '1' })),
3176
+ canUpgrade: secretsProtected,
3177
+ restartsItself: exitOnCodeChange,
3178
+ }),
3102
3179
  )
3103
3180
  return
3104
3181
  }
@@ -3323,6 +3400,16 @@ const server = createServer(async (req, res) => {
3323
3400
  return
3324
3401
  }
3325
3402
 
3403
+ const approversMatch = req.method === 'PUT' && pathname.match(/^\/agents\/([^/]+)\/notifier\/approvers$/)
3404
+ if (approversMatch) {
3405
+ await handleApproversPut(req, res, decodeURIComponent(approversMatch[1]))
3406
+ return
3407
+ }
3408
+ const slackMembersMatch = req.method === 'GET' && pathname.match(/^\/agents\/([^/]+)\/notifier\/slack-members$/)
3409
+ if (slackMembersMatch) {
3410
+ await handleSlackMembers(res, decodeURIComponent(slackMembersMatch[1]))
3411
+ return
3412
+ }
3326
3413
  const slackChannelsMatch = req.method === 'POST' && pathname.match(/^\/agents\/([^/]+)\/notifier\/slack-channels$/)
3327
3414
  if (slackChannelsMatch) {
3328
3415
  await handleSlackChannels(req, res, decodeURIComponent(slackChannelsMatch[1]))
@@ -9,7 +9,7 @@
9
9
  "cli": "tsx adapters/cli.ts"
10
10
  },
11
11
  "dependencies": {
12
- "loopengine": "^0.1.81",
12
+ "loopengine": "^0.1.83",
13
13
  "actauth": "^0.0.12",
14
14
  "tsx": "^4.19.0",
15
15
  "typescript": "^5.7.0"