@poncho-ai/cli 0.13.0 → 0.14.0

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/src/index.ts CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  resolveStateConfig,
25
25
  type CronJobConfig,
26
26
  type PonchoConfig,
27
+ type Conversation,
27
28
  type ConversationStore,
28
29
  type UploadStore,
29
30
  } from "@poncho-ai/harness";
@@ -31,6 +32,7 @@ import type { AgentEvent, FileInput, Message, RunInput } from "@poncho-ai/sdk";
31
32
  import { getTextContent } from "@poncho-ai/sdk";
32
33
  import {
33
34
  AgentBridge,
35
+ ResendAdapter,
34
36
  SlackAdapter,
35
37
  type AgentRunner,
36
38
  type MessagingAdapter,
@@ -53,6 +55,7 @@ import {
53
55
  setCookie,
54
56
  verifyPassphrase,
55
57
  } from "./web-ui.js";
58
+ import { buildOpenApiSpec, renderApiDocsHtml } from "./api-docs.js";
56
59
  import { createInterface } from "node:readline/promises";
57
60
  import {
58
61
  runInitOnboarding,
@@ -374,7 +377,7 @@ cp .env.example .env
374
377
  poncho dev
375
378
  \`\`\`
376
379
 
377
- Open \`http://localhost:3000\` for the web UI.
380
+ Open \`http://localhost:3000\` for the web UI, or \`http://localhost:3000/api/docs\` for interactive API documentation.
378
381
 
379
382
  On your first interactive session, the agent introduces its configurable capabilities.
380
383
  While a response is streaming, you can stop it:
@@ -496,7 +499,7 @@ Core files:
496
499
 
497
500
  - \`AGENT.md\`: behavior, model selection, runtime guidance
498
501
  - \`poncho.config.js\`: runtime config (storage, auth, telemetry, MCP, tools)
499
- - \`.env\`: secrets and environment variables
502
+ - \`.env\`: secrets and environment variables (loaded before the harness starts, so \`process.env\` is available in skill scripts)
500
503
 
501
504
  Example \`poncho.config.js\`:
502
505
 
@@ -522,18 +525,20 @@ export default {
522
525
  auth: { type: "bearer", tokenEnv: "GITHUB_TOKEN" },
523
526
  },
524
527
  ],
528
+ // Tool access: true (available), false (disabled), 'approval' (requires human approval)
525
529
  tools: {
526
- defaults: {
527
- list_directory: true,
528
- read_file: true,
529
- write_file: true, // still gated by environment/policy
530
- },
530
+ write_file: true, // gated by environment for writes
531
+ send_email: 'approval', // requires human approval
531
532
  byEnvironment: {
532
533
  production: {
533
- read_file: false, // example override
534
+ write_file: false,
535
+ },
536
+ development: {
537
+ send_email: true, // skip approval in dev
534
538
  },
535
539
  },
536
540
  },
541
+ // webUi: false, // Disable built-in UI for API-only deployments
537
542
  };
538
543
  \`\`\`
539
544
 
@@ -592,6 +597,26 @@ Connect your agent to Slack so it responds to @mentions:
592
597
  messaging: [{ platform: 'slack' }]
593
598
  \`\`\`
594
599
 
600
+ ## Messaging (Email via Resend)
601
+
602
+ Connect your agent to email so users can interact by sending emails:
603
+
604
+ 1. Set up a domain and enable Inbound at [resend.com](https://resend.com)
605
+ 2. Create a webhook for \`email.received\` pointing to \`https://<your-url>/api/messaging/resend\`
606
+ 3. Install the Resend SDK: \`npm install resend\`
607
+ 4. Set env vars:
608
+ \`\`\`
609
+ RESEND_API_KEY=re_...
610
+ RESEND_WEBHOOK_SECRET=whsec_...
611
+ RESEND_FROM=Agent <agent@yourdomain.com>
612
+ \`\`\`
613
+ 5. Add to \`poncho.config.js\`:
614
+ \`\`\`javascript
615
+ messaging: [{ platform: 'resend' }]
616
+ \`\`\`
617
+
618
+ For full control over outbound emails, use **tool mode** (\`mode: 'tool'\`) — the agent gets a \`send_email\` tool instead of auto-replying. See the repo README for details.
619
+
595
620
  ## Deployment
596
621
 
597
622
  \`\`\`bash
@@ -1430,21 +1455,188 @@ export const createRequestHandler = async (options?: {
1430
1455
  return { messages: [] };
1431
1456
  },
1432
1457
  async run(conversationId, input) {
1433
- const output = await harness.runToCompletion({
1434
- task: input.task,
1435
- messages: input.messages,
1458
+ console.log("[messaging-runner] starting run for", conversationId, "task:", input.task.slice(0, 80));
1459
+
1460
+ const historyMessages = [...input.messages];
1461
+ const userContent = input.task;
1462
+
1463
+ // Read-modify-write helper: always fetches the latest version from
1464
+ // the store before writing, so concurrent writers (e.g. the approval
1465
+ // handler's persistConversationPendingApprovals) don't get clobbered.
1466
+ const updateConversation = async (
1467
+ patch: (conv: Conversation) => void,
1468
+ ): Promise<void> => {
1469
+ const fresh = await conversationStore.get(conversationId);
1470
+ if (!fresh) return;
1471
+ patch(fresh);
1472
+ fresh.updatedAt = Date.now();
1473
+ await conversationStore.update(fresh);
1474
+ };
1475
+
1476
+ // Persist user turn immediately so the web UI shows it while the agent runs.
1477
+ await updateConversation((c) => {
1478
+ c.messages = [...historyMessages, { role: "user" as const, content: userContent }];
1436
1479
  });
1437
- const response = output.result.response ?? "";
1438
1480
 
1439
- const conversation = await conversationStore.get(conversationId);
1440
- if (conversation) {
1441
- conversation.messages = [
1442
- ...input.messages,
1443
- { role: "user" as const, content: input.task },
1444
- { role: "assistant" as const, content: response },
1481
+ let latestRunId = "";
1482
+ let assistantResponse = "";
1483
+ const toolTimeline: string[] = [];
1484
+ const sections: Array<{ type: "text" | "tools"; content: string | string[] }> = [];
1485
+ let currentTools: string[] = [];
1486
+ let currentText = "";
1487
+
1488
+ const buildMessages = (): Message[] => {
1489
+ const draftSections: Array<{ type: "text" | "tools"; content: string | string[] }> = [
1490
+ ...sections.map((s) => ({
1491
+ type: s.type,
1492
+ content: Array.isArray(s.content) ? [...s.content] : s.content,
1493
+ })),
1445
1494
  ];
1446
- await conversationStore.update(conversation);
1495
+ if (currentTools.length > 0) {
1496
+ draftSections.push({ type: "tools", content: [...currentTools] });
1497
+ }
1498
+ if (currentText.length > 0) {
1499
+ draftSections.push({ type: "text", content: currentText });
1500
+ }
1501
+ const hasDraftContent =
1502
+ assistantResponse.length > 0 || toolTimeline.length > 0 || draftSections.length > 0;
1503
+ if (!hasDraftContent) {
1504
+ return [...historyMessages, { role: "user" as const, content: userContent }];
1505
+ }
1506
+ return [
1507
+ ...historyMessages,
1508
+ { role: "user" as const, content: userContent },
1509
+ {
1510
+ role: "assistant" as const,
1511
+ content: assistantResponse,
1512
+ metadata:
1513
+ toolTimeline.length > 0 || draftSections.length > 0
1514
+ ? ({
1515
+ toolActivity: [...toolTimeline],
1516
+ sections: draftSections.length > 0 ? draftSections : undefined,
1517
+ } as Message["metadata"])
1518
+ : undefined,
1519
+ },
1520
+ ];
1521
+ };
1522
+
1523
+ const persistDraftAssistantTurn = async (): Promise<void> => {
1524
+ if (assistantResponse.length === 0 && toolTimeline.length === 0) return;
1525
+ await updateConversation((c) => {
1526
+ c.messages = buildMessages();
1527
+ });
1528
+ };
1529
+
1530
+ const runInput = {
1531
+ task: input.task,
1532
+ conversationId,
1533
+ messages: input.messages,
1534
+ files: input.files,
1535
+ parameters: input.metadata ? {
1536
+ __messaging_platform: input.metadata.platform,
1537
+ __messaging_sender_id: input.metadata.sender.id,
1538
+ __messaging_sender_name: input.metadata.sender.name ?? "",
1539
+ __messaging_thread_id: input.metadata.threadId,
1540
+ } : undefined,
1541
+ };
1542
+
1543
+ try {
1544
+ for await (const event of harness.runWithTelemetry(runInput)) {
1545
+ if (event.type === "run:started") {
1546
+ latestRunId = event.runId;
1547
+ runOwners.set(event.runId, "local-owner");
1548
+ runConversations.set(event.runId, conversationId);
1549
+ }
1550
+ if (event.type === "model:chunk") {
1551
+ if (currentTools.length > 0) {
1552
+ sections.push({ type: "tools", content: currentTools });
1553
+ currentTools = [];
1554
+ }
1555
+ assistantResponse += event.content;
1556
+ currentText += event.content;
1557
+ }
1558
+ if (event.type === "tool:started") {
1559
+ if (currentText.length > 0) {
1560
+ sections.push({ type: "text", content: currentText });
1561
+ currentText = "";
1562
+ }
1563
+ const toolText = `- start \`${event.tool}\``;
1564
+ toolTimeline.push(toolText);
1565
+ currentTools.push(toolText);
1566
+ }
1567
+ if (event.type === "tool:completed") {
1568
+ const toolText = `- done \`${event.tool}\` (${event.duration}ms)`;
1569
+ toolTimeline.push(toolText);
1570
+ currentTools.push(toolText);
1571
+ }
1572
+ if (event.type === "tool:error") {
1573
+ const toolText = `- error \`${event.tool}\`: ${event.error}`;
1574
+ toolTimeline.push(toolText);
1575
+ currentTools.push(toolText);
1576
+ }
1577
+ if (event.type === "step:completed") {
1578
+ await persistDraftAssistantTurn();
1579
+ }
1580
+ if (event.type === "tool:approval:required") {
1581
+ const toolText = `- approval required \`${event.tool}\``;
1582
+ toolTimeline.push(toolText);
1583
+ currentTools.push(toolText);
1584
+ await persistDraftAssistantTurn();
1585
+ await persistConversationPendingApprovals(conversationId);
1586
+ }
1587
+ if (event.type === "tool:approval:granted") {
1588
+ const toolText = `- approval granted (${event.approvalId})`;
1589
+ toolTimeline.push(toolText);
1590
+ currentTools.push(toolText);
1591
+ await persistDraftAssistantTurn();
1592
+ }
1593
+ if (event.type === "tool:approval:denied") {
1594
+ const toolText = `- approval denied (${event.approvalId})`;
1595
+ toolTimeline.push(toolText);
1596
+ currentTools.push(toolText);
1597
+ await persistDraftAssistantTurn();
1598
+ }
1599
+ if (
1600
+ event.type === "run:completed" &&
1601
+ assistantResponse.length === 0 &&
1602
+ event.result.response
1603
+ ) {
1604
+ assistantResponse = event.result.response;
1605
+ }
1606
+ if (event.type === "run:error") {
1607
+ assistantResponse = assistantResponse || `[Error: ${event.error.message}]`;
1608
+ }
1609
+ broadcastEvent(conversationId, event);
1610
+ }
1611
+ } catch (err) {
1612
+ console.error("[messaging-runner] run failed:", err instanceof Error ? err.message : err);
1613
+ assistantResponse = assistantResponse || `[Error: ${err instanceof Error ? err.message : "Unknown error"}]`;
1614
+ }
1615
+
1616
+ // Finalize sections (clear after pushing so buildMessages doesn't re-add)
1617
+ if (currentTools.length > 0) {
1618
+ sections.push({ type: "tools", content: currentTools });
1619
+ currentTools = [];
1447
1620
  }
1621
+ if (currentText.length > 0) {
1622
+ sections.push({ type: "text", content: currentText });
1623
+ currentText = "";
1624
+ }
1625
+
1626
+ await updateConversation((c) => {
1627
+ c.messages = buildMessages();
1628
+ c.runtimeRunId = latestRunId || c.runtimeRunId;
1629
+ c.pendingApprovals = [];
1630
+ });
1631
+ finishConversationStream(conversationId);
1632
+ await persistConversationPendingApprovals(conversationId);
1633
+ if (latestRunId) {
1634
+ runOwners.delete(latestRunId);
1635
+ runConversations.delete(latestRunId);
1636
+ }
1637
+
1638
+ console.log("[messaging-runner] run complete, response length:", assistantResponse.length);
1639
+ const response = assistantResponse;
1448
1640
 
1449
1641
  return { response };
1450
1642
  },
@@ -1475,6 +1667,7 @@ export const createRequestHandler = async (options?: {
1475
1667
  adapter,
1476
1668
  runner: messagingRunner,
1477
1669
  waitUntil: waitUntilHook,
1670
+ ownerId: "local-owner",
1478
1671
  });
1479
1672
  adapter.registerRoutes(messagingRouteRegistrar);
1480
1673
  try {
@@ -1486,6 +1679,37 @@ export const createRequestHandler = async (options?: {
1486
1679
  ` Slack messaging disabled: ${err instanceof Error ? err.message : String(err)}`,
1487
1680
  );
1488
1681
  }
1682
+ } else if (channelConfig.platform === "resend") {
1683
+ const adapter = new ResendAdapter({
1684
+ apiKeyEnv: channelConfig.apiKeyEnv,
1685
+ webhookSecretEnv: channelConfig.webhookSecretEnv,
1686
+ fromEnv: channelConfig.fromEnv,
1687
+ allowedSenders: channelConfig.allowedSenders,
1688
+ mode: channelConfig.mode,
1689
+ allowedRecipients: channelConfig.allowedRecipients,
1690
+ maxSendsPerRun: channelConfig.maxSendsPerRun,
1691
+ });
1692
+ const bridge = new AgentBridge({
1693
+ adapter,
1694
+ runner: messagingRunner,
1695
+ waitUntil: waitUntilHook,
1696
+ ownerId: "local-owner",
1697
+ });
1698
+ adapter.registerRoutes(messagingRouteRegistrar);
1699
+ try {
1700
+ await bridge.start();
1701
+ messagingBridges.push(bridge);
1702
+ const adapterTools = adapter.getToolDefinitions?.() ?? [];
1703
+ if (adapterTools.length > 0) {
1704
+ harness.registerTools(adapterTools);
1705
+ }
1706
+ const modeLabel = channelConfig.mode === "tool" ? "tool" : "auto-reply";
1707
+ console.log(` Resend email messaging enabled at /api/messaging/resend (mode: ${modeLabel})`);
1708
+ } catch (err) {
1709
+ console.warn(
1710
+ ` Resend email messaging disabled: ${err instanceof Error ? err.message : String(err)}`,
1711
+ );
1712
+ }
1489
1713
  }
1490
1714
  }
1491
1715
  }
@@ -1498,6 +1722,7 @@ export const createRequestHandler = async (options?: {
1498
1722
  const authRequired = config?.auth?.required ?? false;
1499
1723
  const requireAuth = authRequired && authToken.length > 0;
1500
1724
 
1725
+ const webUiEnabled = config?.webUi !== false;
1501
1726
  const isProduction = resolveHarnessEnvironment() === "production";
1502
1727
  const secureCookies = isProduction;
1503
1728
 
@@ -1523,41 +1748,52 @@ export const createRequestHandler = async (options?: {
1523
1748
  }
1524
1749
  const [pathname] = request.url.split("?");
1525
1750
 
1526
- if (request.method === "GET" && (pathname === "/" || pathname.startsWith("/c/"))) {
1527
- writeHtml(response, 200, renderWebUiHtml({ agentName }));
1528
- return;
1529
- }
1751
+ if (webUiEnabled) {
1752
+ if (request.method === "GET" && (pathname === "/" || pathname.startsWith("/c/"))) {
1753
+ writeHtml(response, 200, renderWebUiHtml({ agentName }));
1754
+ return;
1755
+ }
1530
1756
 
1531
- if (pathname === "/manifest.json" && request.method === "GET") {
1532
- response.writeHead(200, { "Content-Type": "application/manifest+json" });
1533
- response.end(renderManifest({ agentName }));
1534
- return;
1535
- }
1757
+ if (pathname === "/manifest.json" && request.method === "GET") {
1758
+ response.writeHead(200, { "Content-Type": "application/manifest+json" });
1759
+ response.end(renderManifest({ agentName }));
1760
+ return;
1761
+ }
1536
1762
 
1537
- if (pathname === "/sw.js" && request.method === "GET") {
1538
- response.writeHead(200, {
1539
- "Content-Type": "application/javascript",
1540
- "Service-Worker-Allowed": "/",
1541
- });
1542
- response.end(renderServiceWorker());
1543
- return;
1763
+ if (pathname === "/sw.js" && request.method === "GET") {
1764
+ response.writeHead(200, {
1765
+ "Content-Type": "application/javascript",
1766
+ "Service-Worker-Allowed": "/",
1767
+ });
1768
+ response.end(renderServiceWorker());
1769
+ return;
1770
+ }
1771
+
1772
+ if (pathname === "/icon.svg" && request.method === "GET") {
1773
+ response.writeHead(200, { "Content-Type": "image/svg+xml" });
1774
+ response.end(renderIconSvg({ agentName }));
1775
+ return;
1776
+ }
1777
+
1778
+ if ((pathname === "/icon-192.png" || pathname === "/icon-512.png") && request.method === "GET") {
1779
+ response.writeHead(302, { Location: "/icon.svg" });
1780
+ response.end();
1781
+ return;
1782
+ }
1544
1783
  }
1545
1784
 
1546
- if (pathname === "/icon.svg" && request.method === "GET") {
1547
- response.writeHead(200, { "Content-Type": "image/svg+xml" });
1548
- response.end(renderIconSvg({ agentName }));
1785
+ if (pathname === "/health" && request.method === "GET") {
1786
+ writeJson(response, 200, { status: "ok" });
1549
1787
  return;
1550
1788
  }
1551
1789
 
1552
- if ((pathname === "/icon-192.png" || pathname === "/icon-512.png") && request.method === "GET") {
1553
- // Redirect to SVG — browsers that support PWA icons will use the SVG
1554
- response.writeHead(302, { Location: "/icon.svg" });
1555
- response.end();
1790
+ if (pathname === "/api/openapi.json" && request.method === "GET") {
1791
+ writeJson(response, 200, buildOpenApiSpec({ agentName }));
1556
1792
  return;
1557
1793
  }
1558
1794
 
1559
- if (pathname === "/health" && request.method === "GET") {
1560
- writeJson(response, 200, { status: "ok" });
1795
+ if (pathname === "/api/docs" && request.method === "GET") {
1796
+ writeHtml(response, 200, renderApiDocsHtml("/api/openapi.json"));
1561
1797
  return;
1562
1798
  }
1563
1799
 
@@ -1779,18 +2015,19 @@ export const createRequestHandler = async (options?: {
1779
2015
  });
1780
2016
  const stream = conversationEventStreams.get(conversationId);
1781
2017
  if (!stream) {
1782
- // No active run — close immediately
1783
2018
  response.write("event: stream:end\ndata: {}\n\n");
1784
2019
  response.end();
1785
2020
  return;
1786
2021
  }
1787
- // Replay buffered events
1788
- for (const bufferedEvent of stream.buffer) {
1789
- try {
1790
- response.write(formatSseEvent(bufferedEvent));
1791
- } catch {
1792
- response.end();
1793
- return;
2022
+ const liveOnly = (request.url ?? "").includes("live_only=true");
2023
+ if (!liveOnly) {
2024
+ for (const bufferedEvent of stream.buffer) {
2025
+ try {
2026
+ response.write(formatSseEvent(bufferedEvent));
2027
+ } catch {
2028
+ response.end();
2029
+ return;
2030
+ }
1794
2031
  }
1795
2032
  }
1796
2033
  if (stream.finished) {
@@ -1841,11 +2078,14 @@ export const createRequestHandler = async (options?: {
1841
2078
  for (const approval of livePending) {
1842
2079
  mergedPendingById.set(approval.approvalId, approval);
1843
2080
  }
2081
+ const activeStream = conversationEventStreams.get(conversationId);
2082
+ const hasActiveRun = !!activeStream && !activeStream.finished;
1844
2083
  writeJson(response, 200, {
1845
2084
  conversation: {
1846
2085
  ...conversation,
1847
2086
  pendingApprovals: Array.from(mergedPendingById.values()),
1848
2087
  },
2088
+ hasActiveRun,
1849
2089
  });
1850
2090
  return;
1851
2091
  }
@@ -2124,6 +2364,7 @@ export const createRequestHandler = async (options?: {
2124
2364
 
2125
2365
  for await (const event of harness.runWithTelemetry({
2126
2366
  task: messageText,
2367
+ conversationId,
2127
2368
  parameters: {
2128
2369
  ...(bodyParameters ?? {}),
2129
2370
  __conversationRecallCorpus: recallCorpus,
@@ -2403,6 +2644,7 @@ export const createRequestHandler = async (options?: {
2403
2644
 
2404
2645
  for await (const event of harness.runWithTelemetry({
2405
2646
  task: cronJob.task,
2647
+ conversationId: conversation.conversationId,
2406
2648
  parameters: { __activeConversationId: conversation.conversationId },
2407
2649
  messages: historyMessages,
2408
2650
  abortSignal: abortController.signal,
@@ -2589,6 +2831,7 @@ export const startDevServer = async (
2589
2831
  let currentText = "";
2590
2832
  for await (const event of harness.runWithTelemetry({
2591
2833
  task: config.task,
2834
+ conversationId: conversation.conversationId,
2592
2835
  parameters: { __activeConversationId: conversation.conversationId },
2593
2836
  messages: [],
2594
2837
  })) {
@@ -349,7 +349,20 @@ export const buildConfigFromOnboardingAnswers = (
349
349
  };
350
350
 
351
351
  if (messagingPlatform !== "none") {
352
- config.messaging = [{ platform: messagingPlatform as "slack" }];
352
+ const channelConfig: NonNullable<PonchoConfig["messaging"]>[number] = {
353
+ platform: messagingPlatform as "slack" | "resend",
354
+ };
355
+ if (messagingPlatform === "resend") {
356
+ const mode = String(answers["messaging.resend.mode"] ?? "auto-reply");
357
+ if (mode === "tool") {
358
+ channelConfig.mode = "tool";
359
+ }
360
+ const recipientsRaw = String(answers["messaging.resend.allowedRecipients"] ?? "");
361
+ if (recipientsRaw.trim().length > 0) {
362
+ channelConfig.allowedRecipients = recipientsRaw.split(",").map((s) => s.trim()).filter(Boolean);
363
+ }
364
+ }
365
+ config.messaging = [channelConfig];
353
366
  }
354
367
 
355
368
  return config;