create-loopengine 0.1.62 → 0.1.64

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.62",
3
+ "version": "0.1.64",
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",
@@ -2,6 +2,8 @@ node_modules
2
2
  dist
3
3
  .sessions
4
4
  .checkpoints
5
+ # Slack threads matched to their requests (agents/<name>/.messaging).
6
+ .messaging
5
7
  .scheduler
6
8
  .env
7
9
  *.log
@@ -129,6 +129,10 @@ import {
129
129
  replyInSlack,
130
130
  slackOutcomeText,
131
131
  verifySlackRequest,
132
+ firstSighting,
133
+ handleInboundMessage,
134
+ createSlackConnector,
135
+ parseSlackEvent,
132
136
  removeAgentNotifier,
133
137
  saveAgentNotifier,
134
138
  testAgentNotifier,
@@ -2350,6 +2354,10 @@ async function answerPendingQuestion(res: ServerResponse, pendingId: string, bod
2350
2354
  * resumeAgent(), through the same durable sessions.withSession append
2351
2355
  * path any other turn goes through. */
2352
2356
  async function respondAfterResolution(res: ServerResponse, finalCheckpoint: TurnCheckpoint): Promise<void> {
2357
+ await tracked(() => respondAfterResolutionNow(res, finalCheckpoint))
2358
+ }
2359
+
2360
+ async function respondAfterResolutionNow(res: ServerResponse, finalCheckpoint: TurnCheckpoint): Promise<void> {
2353
2361
  if (Object.keys(finalCheckpoint.outstanding).length > 0) {
2354
2362
  res.writeHead(200, { 'content-type': 'application/json' }).end(
2355
2363
  JSON.stringify({ resolved: true, outstanding: Object.keys(finalCheckpoint.outstanding).length }),
@@ -2584,11 +2592,24 @@ function exitIfCodeChanged(res: ServerResponse): void {
2584
2592
  // checks before stopping an idle server, so it never stops one mid-turn.
2585
2593
  let openStreams = 0
2586
2594
  let lastRequestAt = Date.now()
2595
+ // Work still running after its request was answered (a Slack thread reply
2596
+ // being answered, a run resumed after a decision), so a host never
2597
+ // suspends this server in the middle of it.
2598
+ let backgroundWork = 0
2599
+
2600
+ async function tracked<T>(fn: () => Promise<T>): Promise<T> {
2601
+ backgroundWork++
2602
+ try {
2603
+ return await fn()
2604
+ } finally {
2605
+ backgroundWork--
2606
+ }
2607
+ }
2587
2608
 
2588
2609
  async function handleActivityGet(res: ServerResponse): Promise<void> {
2589
2610
  res.writeHead(200, { 'content-type': 'application/json' }).end(
2590
2611
  JSON.stringify({
2591
- inFlightTurns: sessionTurns.size + scheduler.running,
2612
+ inFlightTurns: sessionTurns.size + scheduler.running + backgroundWork,
2592
2613
  scheduledRuns: scheduler.running,
2593
2614
  // When the next scheduled task is due — wake this server by then
2594
2615
  // (POST /internal/scheduler/tick) if it's been stopped while idle.
@@ -2666,7 +2687,7 @@ function captureResponse(): { res: ServerResponse; done: Promise<{ status: numbe
2666
2687
  return { res: res as unknown as ServerResponse, done }
2667
2688
  }
2668
2689
 
2669
- // A Slack app's Interactivity Request URL (core/slack-interactions.ts):
2690
+ // A Slack app's Interactivity Request URL (core/messaging/connectors/slack/interactions.ts):
2670
2691
  // Approve/Deny, an answer option, or the "Other…" modal, from a Slack
2671
2692
  // notifier's message. Outside the admin login on purpose — Slack can't
2672
2693
  // send it — and verified with that agent's Slack signing secret instead.
@@ -2705,6 +2726,52 @@ async function handleSlackInteraction(req: IncomingMessage, res: ServerResponse,
2705
2726
  }
2706
2727
  }
2707
2728
 
2729
+ // A Slack app's Event Subscriptions Request URL (core/messaging/):
2730
+ // a reply in the thread under an approval request or a question, which
2731
+ // the agent answers about that request. Outside the admin login like the
2732
+ // button endpoint above, and verified with the same signing secret.
2733
+ // Slack's URL check is answered before that, since the app is created
2734
+ // before its signing secret can be saved here; it only echoes Slack's
2735
+ // own challenge back.
2736
+ async function handleSlackEvents(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
2737
+ const rawBody = await readRawBody(req)
2738
+ const entry = getEntry(agentName)
2739
+ if (!entry) {
2740
+ res.writeHead(404, { 'content-type': 'text/plain' }).end('No such agent.')
2741
+ return
2742
+ }
2743
+ const event = parseSlackEvent(rawBody)
2744
+ if (event.type === 'url_verification') {
2745
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ challenge: event.challenge }))
2746
+ return
2747
+ }
2748
+ const { botToken, signingSecret } = slackCredentialsFor(entry.config)
2749
+ if (!signingSecret || !botToken) {
2750
+ res.writeHead(404, { 'content-type': 'text/plain' }).end('No Slack bot token and signing secret are set for this agent.')
2751
+ return
2752
+ }
2753
+ const timestamp = req.headers['x-slack-request-timestamp']
2754
+ const signature = req.headers['x-slack-signature']
2755
+ if (!verifySlackRequest(rawBody, typeof timestamp === 'string' ? timestamp : undefined, typeof signature === 'string' ? signature : undefined, signingSecret)) {
2756
+ res.writeHead(401, { 'content-type': 'text/plain' }).end('Invalid Slack signature.')
2757
+ return
2758
+ }
2759
+ // Slack wants an answer within three seconds; the reply is posted after.
2760
+ res.writeHead(200).end()
2761
+ if (event.type !== 'message' || !firstSighting(event)) return
2762
+ const environment = process.env.LOOPENGINE_ENV ?? 'production'
2763
+ await handleInboundMessage(event, {
2764
+ agentName,
2765
+ agentDir: agentDirFor(entry.config),
2766
+ systemPrompt: entry.config.systemPrompt,
2767
+ connector: createSlackConnector(botToken),
2768
+ createModelCall: () => entry.createModelCall(),
2769
+ getCheckpoint: (pendingId) => checkpoints.get(pendingId),
2770
+ getHistory: (checkpoint) => sessions.getHistory(`${checkpoint.tenant}:${environment}:${checkpoint.agent}:${checkpoint.sessionId}`),
2771
+ plan: process.env.LOOPENGINE_PLAN,
2772
+ })
2773
+ }
2774
+
2708
2775
  // Set once shutdown() starts, so /healthz turns 503 while the server
2709
2776
  // drains and a router or load balancer stops sending it new requests.
2710
2777
  let shuttingDown = false
@@ -2719,10 +2786,23 @@ const server = createServer(async (req, res) => {
2719
2786
  return
2720
2787
  }
2721
2788
 
2789
+ const slackEventsMatch = req.method === 'POST' && (req.url ?? '/').split('?')[0].match(/^\/hooks\/slack\/([^/]+)\/events$/)
2790
+ if (slackEventsMatch) {
2791
+ try {
2792
+ lastRequestAt = Date.now()
2793
+ await tracked(() => handleSlackEvents(req, res, decodeURIComponent(slackEventsMatch[1])))
2794
+ } catch (err) {
2795
+ console.error('[loopengine] Slack event failed:', err)
2796
+ if (!res.headersSent) res.writeHead(500).end()
2797
+ }
2798
+ return
2799
+ }
2800
+
2722
2801
  const slackHookMatch = req.method === 'POST' && (req.url ?? '/').split('?')[0].match(/^\/hooks\/slack\/([^/]+)$/)
2723
2802
  if (slackHookMatch) {
2724
2803
  try {
2725
- await handleSlackInteraction(req, res, decodeURIComponent(slackHookMatch[1]))
2804
+ lastRequestAt = Date.now()
2805
+ await tracked(() => handleSlackInteraction(req, res, decodeURIComponent(slackHookMatch[1])))
2726
2806
  } catch (err) {
2727
2807
  console.error('[loopengine] Slack interaction failed:', err)
2728
2808
  if (!res.headersSent) res.writeHead(500).end()
@@ -2733,7 +2813,8 @@ const server = createServer(async (req, res) => {
2733
2813
  const decideMatch = (req.method === 'GET' || req.method === 'POST') && (req.url ?? '/').split('?')[0].match(/^\/decide\/([A-Za-z0-9_.-]+)$/)
2734
2814
  if (decideMatch) {
2735
2815
  try {
2736
- await handleDecide(req, res, decideMatch[1])
2816
+ lastRequestAt = Date.now()
2817
+ await tracked(() => handleDecide(req, res, decideMatch[1]))
2737
2818
  } catch (err) {
2738
2819
  console.error('[loopengine] decision link failed:', err)
2739
2820
  if (!res.headersSent) res.writeHead(500, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'Something went wrong; try again.' }))
@@ -9,7 +9,7 @@
9
9
  "cli": "tsx adapters/cli.ts"
10
10
  },
11
11
  "dependencies": {
12
- "loopengine": "^0.1.77",
12
+ "loopengine": "^0.1.79",
13
13
  "actauth": "^0.0.12",
14
14
  "tsx": "^4.19.0",
15
15
  "typescript": "^5.7.0"