create-loopengine 0.1.66 → 0.1.68

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.66",
3
+ "version": "0.1.68",
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,17 @@ 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,
135
+ type MessagingDeps,
136
+ handleMessagingAction,
137
+ agentConfirmTools,
138
+ setAgentConfirmTools,
128
139
  openSlackAnswerModal,
129
140
  parseSlackInteraction,
130
141
  replyInSlack,
@@ -1256,6 +1267,66 @@ async function handleSlackChannels(req: IncomingMessage, res: ServerResponse, ag
1256
1267
  }
1257
1268
  }
1258
1269
 
1270
+ // Messaging tab → Approvers: who can decide this agent's Slack requests
1271
+ // by replying (core/messaging/), and the people in its channel to pick
1272
+ // from. Changing the list needs the admin login, like saving a secret:
1273
+ // it decides who can approve what the agent does.
1274
+ async function handleApproversPut(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
1275
+ const entry = getEntry(agentName)
1276
+ if (!entry) {
1277
+ res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
1278
+ return
1279
+ }
1280
+ if (!secretsProtected) {
1281
+ res.writeHead(403, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'Changing approvers needs LOOPENGINE_ADMIN_AUTH set on the server.' }))
1282
+ return
1283
+ }
1284
+ const body = await readJsonBody(req)
1285
+ try {
1286
+ const approvers = setAgentApprovers(entry.config, body.approvers)
1287
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ approvers }))
1288
+ } catch (err) {
1289
+ const status = err instanceof NotifierFileError ? 400 : 500
1290
+ res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
1291
+ }
1292
+ }
1293
+
1294
+ // Messaging tab → Confirm-required tools: tools whose approval by reply
1295
+ // needs a Confirm click. Admin login needed, like the approvers.
1296
+ async function handleConfirmToolsPut(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
1297
+ const entry = getEntry(agentName)
1298
+ if (!entry) {
1299
+ res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
1300
+ return
1301
+ }
1302
+ if (!secretsProtected) {
1303
+ res.writeHead(403, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'Changing Confirm-required tools needs LOOPENGINE_ADMIN_AUTH set on the server.' }))
1304
+ return
1305
+ }
1306
+ const body = await readJsonBody(req)
1307
+ try {
1308
+ const tools = setAgentConfirmTools(entry.config, body.tools)
1309
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ tools }))
1310
+ } catch (err) {
1311
+ const status = err instanceof NotifierFileError ? 400 : 500
1312
+ res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
1313
+ }
1314
+ }
1315
+
1316
+ async function handleSlackMembers(res: ServerResponse, agentName: string): Promise<void> {
1317
+ const entry = getEntry(agentName)
1318
+ if (!entry) {
1319
+ res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
1320
+ return
1321
+ }
1322
+ try {
1323
+ const members = await listAgentSlackMembers(entry.config)
1324
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ members }))
1325
+ } catch (err) {
1326
+ res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
1327
+ }
1328
+ }
1329
+
1259
1330
  // Every approval/question still waiting for this agent — for an app that
1260
1331
  // can't receive webhooks (it polls this instead) and for the console.
1261
1332
  async function handlePendingGet(res: ServerResponse, agentName: string): Promise<void> {
@@ -2709,6 +2780,17 @@ async function handleSlackInteraction(req: IncomingMessage, res: ServerResponse,
2709
2780
  return
2710
2781
  }
2711
2782
  const interaction = parseSlackInteraction(rawBody)
2783
+ // Once the agent has approvers (Messaging tab), only their clicks count.
2784
+ if (interaction.type !== 'ignore') {
2785
+ const gate = slackClickAllowed(entry.config, interaction.user)
2786
+ if (!gate.allowed) {
2787
+ res.writeHead(200).end()
2788
+ if ('responseUrl' in interaction && interaction.responseUrl) {
2789
+ await replyPrivatelyInSlack(interaction.responseUrl, gate.message ?? 'Only approvers can decide this.').catch((err) => console.error('[loopengine] Slack reply failed:', err))
2790
+ }
2791
+ return
2792
+ }
2793
+ }
2712
2794
  if (interaction.type === 'open_answer') {
2713
2795
  if (botToken) await openSlackAnswerModal(botToken, interaction.triggerId, interaction.pendingId).catch((err) => console.error('[loopengine] Slack modal failed:', err))
2714
2796
  res.writeHead(200).end()
@@ -2717,23 +2799,39 @@ async function handleSlackInteraction(req: IncomingMessage, res: ServerResponse,
2717
2799
  res.writeHead(200).end()
2718
2800
  if (interaction.type === 'ignore') return
2719
2801
 
2802
+ // A button under one of the agent's own thread replies: Approve as
2803
+ // changed, Keep original, Confirm, Cancel (core/messaging/engine.ts).
2804
+ if (interaction.type === 'action') {
2805
+ if (!botToken) return
2806
+ const text = await handleMessagingAction(interaction.action, interaction.pendingId, interaction.user ?? '', slackMessagingDeps(agentName, entry, botToken))
2807
+ if (interaction.responseUrl) await replyInSlack(interaction.responseUrl, text).catch((err) => console.error('[loopengine] Slack reply failed:', err))
2808
+ return
2809
+ }
2810
+
2811
+ // Approve reports what ran, so the final arguments are in the channel.
2812
+ let ran = ''
2813
+ if (interaction.type === 'decide' && interaction.decision === 'approve') {
2814
+ const item = (await checkpoints.get(interaction.pendingId))?.outstanding[interaction.pendingId]
2815
+ if (item) ran = `\nRan \`${item.tool}\` with \`${JSON.stringify(item.args)}\`.`
2816
+ }
2720
2817
  const capture = captureResponse()
2721
2818
  if (interaction.type === 'decide') await resolvePendingApproval(capture.res, interaction.pendingId, { decision: interaction.decision })
2722
2819
  else await answerPendingQuestion(capture.res, interaction.pendingId, { answer: interaction.answer })
2723
2820
  const outcome = await capture.done
2724
2821
  if (interaction.responseUrl) {
2725
- const text = slackOutcomeText(interaction.type === 'decide' ? interaction.decision : 'answer', outcome.status, outcome.body, interaction.user)
2822
+ let text = slackOutcomeText(interaction.type === 'decide' ? interaction.decision : 'answer', outcome.status, outcome.body, interaction.user)
2823
+ if (ran && outcome.status < 400 && !outcome.body.alreadyResolved) text += ran
2726
2824
  await replyInSlack(interaction.responseUrl, text).catch((err) => console.error('[loopengine] Slack reply failed:', err))
2727
2825
  }
2728
2826
  }
2729
2827
 
2730
2828
  // A Slack app's Event Subscriptions Request URL (core/messaging/):
2731
2829
  // a reply in the thread under an approval request or a question, which
2732
- // the agent answers about that request. Outside the admin login like the
2733
- // button endpoint above, and verified with the same signing secret.
2734
- // Slack's URL check is answered before that, since the app is created
2735
- // before its signing secret can be saved here; it only echoes Slack's
2736
- // own challenge back.
2830
+ // the agent answers about that request, and which an approver can use to
2831
+ // decide or change it. Outside the admin login like the button endpoint
2832
+ // above, and verified with the same signing secret. Slack's URL check is
2833
+ // answered before that, since the app is created before its signing
2834
+ // secret can be saved here; it only echoes Slack's own challenge back.
2737
2835
  async function handleSlackEvents(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
2738
2836
  const rawBody = await readRawBody(req)
2739
2837
  const entry = getEntry(agentName)
@@ -2760,8 +2858,14 @@ async function handleSlackEvents(req: IncomingMessage, res: ServerResponse, agen
2760
2858
  // Slack wants an answer within three seconds; the reply is posted after.
2761
2859
  res.writeHead(200).end()
2762
2860
  if (event.type !== 'message' || !firstSighting(event) || !slackConversationsOn(entry.config)) return
2861
+ await handleInboundMessage(event, slackMessagingDeps(agentName, entry, botToken))
2862
+ }
2863
+
2864
+ // What core/messaging/ needs from this server to answer, decide and
2865
+ // change an agent's Slack requests.
2866
+ function slackMessagingDeps(agentName: string, entry: RegistryEntry, botToken: string): MessagingDeps {
2763
2867
  const environment = process.env.LOOPENGINE_ENV ?? 'production'
2764
- await handleInboundMessage(event, {
2868
+ return {
2765
2869
  agentName,
2766
2870
  agentDir: agentDirFor(entry.config),
2767
2871
  systemPrompt: entry.config.systemPrompt,
@@ -2770,7 +2874,40 @@ async function handleSlackEvents(req: IncomingMessage, res: ServerResponse, agen
2770
2874
  getCheckpoint: (pendingId) => checkpoints.get(pendingId),
2771
2875
  getHistory: (checkpoint) => sessions.getHistory(`${checkpoint.tenant}:${environment}:${checkpoint.agent}:${checkpoint.sessionId}`),
2772
2876
  plan: process.env.LOOPENGINE_PLAN,
2773
- })
2877
+ approvers: () => agentApprovers(entry.config),
2878
+ confirmTools: () => agentConfirmTools(entry.config),
2879
+ decide: decideFromMessaging,
2880
+ toolSchema: async (toolName) => (await resolveAgentTool(entry.config, toolName))?.input_schema,
2881
+ // The same checks the console's approve-with-edits applies, plus the
2882
+ // permission rules: changed arguments a rule denies are refused.
2883
+ checkArgs: async (pendingId, args) => {
2884
+ const checkpoint = await checkpoints.get(pendingId)
2885
+ const item = checkpoint?.outstanding[pendingId]
2886
+ if (!checkpoint || !item) return 'the request was already decided'
2887
+ const tool = await resolveAgentTool(entry.config, item.tool)
2888
+ if (!tool) return `the tool '${item.tool}' no longer exists`
2889
+ const invalid = validateEditedArgs(args, tool.input_schema)
2890
+ if (invalid) return `the arguments ${invalid}`
2891
+ const [decision, rule] = loadRules(entry.config).resolve(item.tool, args, { tenant: checkpoint.tenant, environment, agent: checkpoint.agent })
2892
+ if (decision === 'deny') return `a permission rule${rule?.name ? ` (${rule.name})` : ''} doesn't allow it`
2893
+ return undefined
2894
+ },
2895
+ }
2896
+ }
2897
+
2898
+ // A decision made by replying in a request's thread (core/messaging/),
2899
+ // recorded through the same resolve and answer handlers as the console's
2900
+ // buttons.
2901
+ async function decideFromMessaging(pendingId: string, decision: MessagingDecision): Promise<DecisionOutcome> {
2902
+ const capture = captureResponse()
2903
+ if (decision.kind === 'answer') await answerPendingQuestion(capture.res, pendingId, { answer: decision.answer })
2904
+ else await resolvePendingApproval(capture.res, pendingId, { decision: decision.kind, ...(decision.kind === 'approve' && decision.editedArgs ? { editedArgs: decision.editedArgs } : {}) })
2905
+ const { status, body } = await capture.done
2906
+ if (status >= 400) return { error: typeof body.error === 'string' ? body.error : `HTTP ${status}` }
2907
+ if (body.alreadyResolved) return { alreadyResolved: true }
2908
+ if (body.resolved) return { stillWaiting: true }
2909
+ if (Array.isArray(body.pending) && body.pending.length) return { morePending: true }
2910
+ return { text: typeof body.text === 'string' ? body.text : undefined }
2774
2911
  }
2775
2912
 
2776
2913
  // Set once shutdown() starts, so /healthz turns 503 while the server
@@ -3327,6 +3464,21 @@ const server = createServer(async (req, res) => {
3327
3464
  return
3328
3465
  }
3329
3466
 
3467
+ const approversMatch = req.method === 'PUT' && pathname.match(/^\/agents\/([^/]+)\/notifier\/approvers$/)
3468
+ if (approversMatch) {
3469
+ await handleApproversPut(req, res, decodeURIComponent(approversMatch[1]))
3470
+ return
3471
+ }
3472
+ const confirmToolsMatch = req.method === 'PUT' && pathname.match(/^\/agents\/([^/]+)\/notifier\/confirm-tools$/)
3473
+ if (confirmToolsMatch) {
3474
+ await handleConfirmToolsPut(req, res, decodeURIComponent(confirmToolsMatch[1]))
3475
+ return
3476
+ }
3477
+ const slackMembersMatch = req.method === 'GET' && pathname.match(/^\/agents\/([^/]+)\/notifier\/slack-members$/)
3478
+ if (slackMembersMatch) {
3479
+ await handleSlackMembers(res, decodeURIComponent(slackMembersMatch[1]))
3480
+ return
3481
+ }
3330
3482
  const slackChannelsMatch = req.method === 'POST' && pathname.match(/^\/agents\/([^/]+)\/notifier\/slack-channels$/)
3331
3483
  if (slackChannelsMatch) {
3332
3484
  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.82",
12
+ "loopengine": "^0.1.84",
13
13
  "actauth": "^0.0.12",
14
14
  "tsx": "^4.19.0",
15
15
  "typescript": "^5.7.0"