@poncho-ai/cli 0.13.0 → 0.14.1

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
@@ -1290,16 +1315,6 @@ export const createRequestHandler = async (options?: {
1290
1315
  runId: string | null;
1291
1316
  };
1292
1317
  const activeConversationRuns = new Map<string, ActiveConversationRun>();
1293
- type PendingApproval = {
1294
- ownerId: string;
1295
- runId: string;
1296
- conversationId: string | null;
1297
- tool: string;
1298
- input: Record<string, unknown>;
1299
- resolve: (approved: boolean) => void;
1300
- };
1301
- const pendingApprovals = new Map<string, PendingApproval>();
1302
-
1303
1318
  // Per-conversation event streaming: buffer events and allow SSE subscribers
1304
1319
  type ConversationEventStream = {
1305
1320
  buffer: AgentEvent[];
@@ -1339,55 +1354,19 @@ export const createRequestHandler = async (options?: {
1339
1354
  setTimeout(() => conversationEventStreams.delete(conversationId), 30_000);
1340
1355
  }
1341
1356
  };
1342
- const persistConversationPendingApprovals = async (conversationId: string): Promise<void> => {
1343
- const conversation = await conversationStore.get(conversationId);
1344
- if (!conversation) {
1345
- return;
1346
- }
1347
- conversation.pendingApprovals = Array.from(pendingApprovals.entries())
1348
- .filter(
1349
- ([, pending]) =>
1350
- pending.ownerId === conversation.ownerId && pending.conversationId === conversationId,
1351
- )
1352
- .map(([approvalId, pending]) => ({
1353
- approvalId,
1354
- runId: pending.runId,
1355
- tool: pending.tool,
1356
- input: pending.input,
1357
- }));
1358
- await conversationStore.update(conversation);
1359
- };
1360
1357
  const clearPendingApprovalsForConversation = async (conversationId: string): Promise<void> => {
1361
- for (const [approvalId, pending] of pendingApprovals.entries()) {
1362
- if (pending.conversationId !== conversationId) {
1363
- continue;
1364
- }
1365
- pendingApprovals.delete(approvalId);
1366
- pending.resolve(false);
1358
+ const conversation = await conversationStore.get(conversationId);
1359
+ if (!conversation) return;
1360
+ if (Array.isArray(conversation.pendingApprovals) && conversation.pendingApprovals.length > 0) {
1361
+ conversation.pendingApprovals = [];
1362
+ await conversationStore.update(conversation);
1367
1363
  }
1368
- await persistConversationPendingApprovals(conversationId);
1369
1364
  };
1370
1365
  const uploadStore = await createUploadStore(config?.uploads, workingDir);
1371
1366
  const harness = new AgentHarness({
1372
1367
  workingDir,
1373
1368
  environment: resolveHarnessEnvironment(),
1374
1369
  uploadStore,
1375
- approvalHandler: async (request) =>
1376
- new Promise<boolean>((resolveApproval) => {
1377
- const ownerIdForRun = runOwners.get(request.runId) ?? "local-owner";
1378
- const conversationIdForRun = runConversations.get(request.runId) ?? null;
1379
- pendingApprovals.set(request.approvalId, {
1380
- ownerId: ownerIdForRun,
1381
- runId: request.runId,
1382
- conversationId: conversationIdForRun,
1383
- tool: request.tool,
1384
- input: request.input,
1385
- resolve: resolveApproval,
1386
- });
1387
- if (conversationIdForRun) {
1388
- void persistConversationPendingApprovals(conversationIdForRun);
1389
- }
1390
- }),
1391
1370
  });
1392
1371
  await harness.initialize();
1393
1372
  const telemetry = new TelemetryEmitter(config?.telemetry);
@@ -1396,6 +1375,186 @@ export const createRequestHandler = async (options?: {
1396
1375
  workingDir,
1397
1376
  agentId: identity.id,
1398
1377
  });
1378
+ // ---------------------------------------------------------------------------
1379
+ // Resume a run from a persisted checkpoint after approval.
1380
+ // Processes events the same way the interactive/messaging runners do.
1381
+ // ---------------------------------------------------------------------------
1382
+ const resumeRunFromCheckpoint = async (
1383
+ conversationId: string,
1384
+ conversation: Conversation,
1385
+ checkpoint: NonNullable<Conversation["pendingApprovals"]>[number],
1386
+ toolResults: Array<{ callId: string; toolName: string; result?: unknown; error?: string }>,
1387
+ ): Promise<void> => {
1388
+ const abortController = new AbortController();
1389
+ activeConversationRuns.set(conversationId, {
1390
+ ownerId: conversation.ownerId,
1391
+ abortController,
1392
+ runId: null,
1393
+ });
1394
+ let latestRunId = conversation.runtimeRunId ?? "";
1395
+ let assistantResponse = "";
1396
+ const toolTimeline: string[] = [];
1397
+ const sections: Array<{ type: "text" | "tools"; content: string | string[] }> = [];
1398
+ let currentText = "";
1399
+ let currentTools: string[] = [];
1400
+ let checkpointedRun = false;
1401
+
1402
+ const baseMessages = checkpoint.baseMessageCount != null
1403
+ ? conversation.messages.slice(0, checkpoint.baseMessageCount)
1404
+ : [];
1405
+ const fullCheckpointMessages = [...baseMessages, ...checkpoint.checkpointMessages!];
1406
+
1407
+ try {
1408
+ for await (const event of harness.continueFromToolResult({
1409
+ messages: fullCheckpointMessages,
1410
+ toolResults,
1411
+ conversationId,
1412
+ abortSignal: abortController.signal,
1413
+ })) {
1414
+ if (event.type === "run:started") {
1415
+ latestRunId = event.runId;
1416
+ runOwners.set(event.runId, conversation.ownerId);
1417
+ runConversations.set(event.runId, conversationId);
1418
+ const active = activeConversationRuns.get(conversationId);
1419
+ if (active && active.abortController === abortController) {
1420
+ active.runId = event.runId;
1421
+ }
1422
+ }
1423
+ if (event.type === "model:chunk") {
1424
+ if (currentTools.length > 0) {
1425
+ sections.push({ type: "tools", content: currentTools });
1426
+ currentTools = [];
1427
+ }
1428
+ assistantResponse += event.content;
1429
+ currentText += event.content;
1430
+ }
1431
+ if (event.type === "tool:started") {
1432
+ if (currentText.length > 0) {
1433
+ sections.push({ type: "text", content: currentText });
1434
+ currentText = "";
1435
+ }
1436
+ const toolText = `- start \`${event.tool}\``;
1437
+ toolTimeline.push(toolText);
1438
+ currentTools.push(toolText);
1439
+ }
1440
+ if (event.type === "tool:completed") {
1441
+ const toolText = `- done \`${event.tool}\` (${event.duration}ms)`;
1442
+ toolTimeline.push(toolText);
1443
+ currentTools.push(toolText);
1444
+ }
1445
+ if (event.type === "tool:error") {
1446
+ const toolText = `- error \`${event.tool}\`: ${event.error}`;
1447
+ toolTimeline.push(toolText);
1448
+ currentTools.push(toolText);
1449
+ }
1450
+ if (event.type === "tool:approval:required") {
1451
+ const toolText = `- approval required \`${event.tool}\``;
1452
+ toolTimeline.push(toolText);
1453
+ currentTools.push(toolText);
1454
+ }
1455
+ if (event.type === "tool:approval:checkpoint") {
1456
+ const conv = await conversationStore.get(conversationId);
1457
+ if (conv) {
1458
+ conv.pendingApprovals = [{
1459
+ approvalId: event.approvalId,
1460
+ runId: latestRunId,
1461
+ tool: event.tool,
1462
+ toolCallId: event.toolCallId,
1463
+ input: event.input,
1464
+ checkpointMessages: [...fullCheckpointMessages, ...event.checkpointMessages],
1465
+ baseMessageCount: 0,
1466
+ pendingToolCalls: event.pendingToolCalls,
1467
+ }];
1468
+ conv.updatedAt = Date.now();
1469
+ await conversationStore.update(conv);
1470
+ }
1471
+ checkpointedRun = true;
1472
+ }
1473
+ if (
1474
+ event.type === "run:completed" &&
1475
+ assistantResponse.length === 0 &&
1476
+ event.result.response
1477
+ ) {
1478
+ assistantResponse = event.result.response;
1479
+ }
1480
+ if (event.type === "run:error") {
1481
+ assistantResponse = assistantResponse || `[Error: ${event.error.message}]`;
1482
+ }
1483
+ await telemetry.emit(event);
1484
+ broadcastEvent(conversationId, event);
1485
+ }
1486
+ } catch (err) {
1487
+ console.error("[resume-run] error:", err instanceof Error ? err.message : err);
1488
+ assistantResponse = assistantResponse || `[Error: ${err instanceof Error ? err.message : "Unknown error"}]`;
1489
+ }
1490
+
1491
+ if (currentTools.length > 0) {
1492
+ sections.push({ type: "tools", content: currentTools });
1493
+ }
1494
+ if (currentText.length > 0) {
1495
+ sections.push({ type: "text", content: currentText });
1496
+ }
1497
+
1498
+ if (!checkpointedRun) {
1499
+ const conv = await conversationStore.get(conversationId);
1500
+ if (conv) {
1501
+ const prevMessages = conv.messages;
1502
+ const hasAssistantContent =
1503
+ assistantResponse.length > 0 || toolTimeline.length > 0 || sections.length > 0;
1504
+ if (hasAssistantContent) {
1505
+ const lastMsg = prevMessages[prevMessages.length - 1];
1506
+ if (lastMsg && lastMsg.role === "assistant" && lastMsg.metadata) {
1507
+ const existingToolActivity = (lastMsg.metadata as Record<string, unknown>).toolActivity;
1508
+ const existingSections = (lastMsg.metadata as Record<string, unknown>).sections;
1509
+ const mergedTimeline = [
1510
+ ...(Array.isArray(existingToolActivity) ? existingToolActivity as string[] : []),
1511
+ ...toolTimeline,
1512
+ ];
1513
+ const mergedSections = [
1514
+ ...(Array.isArray(existingSections) ? existingSections as Array<{ type: "text" | "tools"; content: string | string[] }> : []),
1515
+ ...sections,
1516
+ ];
1517
+ const mergedText = (typeof lastMsg.content === "string" ? lastMsg.content : "") + assistantResponse;
1518
+ conv.messages = [
1519
+ ...prevMessages.slice(0, -1),
1520
+ {
1521
+ role: "assistant" as const,
1522
+ content: mergedText,
1523
+ metadata: {
1524
+ toolActivity: mergedTimeline,
1525
+ sections: mergedSections.length > 0 ? mergedSections : undefined,
1526
+ } as Message["metadata"],
1527
+ },
1528
+ ];
1529
+ } else {
1530
+ conv.messages = [
1531
+ ...prevMessages,
1532
+ {
1533
+ role: "assistant" as const,
1534
+ content: assistantResponse,
1535
+ metadata: (toolTimeline.length > 0 || sections.length > 0
1536
+ ? { toolActivity: toolTimeline, sections: sections.length > 0 ? sections : undefined }
1537
+ : undefined) as Message["metadata"],
1538
+ },
1539
+ ];
1540
+ }
1541
+ }
1542
+ conv.runtimeRunId = latestRunId || conv.runtimeRunId;
1543
+ conv.pendingApprovals = [];
1544
+ conv.updatedAt = Date.now();
1545
+ await conversationStore.update(conv);
1546
+ }
1547
+ }
1548
+
1549
+ finishConversationStream(conversationId);
1550
+ activeConversationRuns.delete(conversationId);
1551
+ if (latestRunId) {
1552
+ runOwners.delete(latestRunId);
1553
+ runConversations.delete(latestRunId);
1554
+ }
1555
+ console.log("[resume-run] complete for", conversationId);
1556
+ };
1557
+
1399
1558
  // ---------------------------------------------------------------------------
1400
1559
  // Messaging adapters (Slack, etc.) — routes bypass Poncho auth; each
1401
1560
  // adapter handles its own request verification (e.g. Slack signing secret).
@@ -1430,21 +1589,192 @@ export const createRequestHandler = async (options?: {
1430
1589
  return { messages: [] };
1431
1590
  },
1432
1591
  async run(conversationId, input) {
1433
- const output = await harness.runToCompletion({
1434
- task: input.task,
1435
- messages: input.messages,
1592
+ console.log("[messaging-runner] starting run for", conversationId, "task:", input.task.slice(0, 80));
1593
+
1594
+ const historyMessages = [...input.messages];
1595
+ const userContent = input.task;
1596
+
1597
+ // Read-modify-write helper: always fetches the latest version from
1598
+ // the store before writing, so concurrent writers don't get clobbered.
1599
+ const updateConversation = async (
1600
+ patch: (conv: Conversation) => void,
1601
+ ): Promise<void> => {
1602
+ const fresh = await conversationStore.get(conversationId);
1603
+ if (!fresh) return;
1604
+ patch(fresh);
1605
+ fresh.updatedAt = Date.now();
1606
+ await conversationStore.update(fresh);
1607
+ };
1608
+
1609
+ // Persist user turn immediately so the web UI shows it while the agent runs.
1610
+ await updateConversation((c) => {
1611
+ c.messages = [...historyMessages, { role: "user" as const, content: userContent }];
1436
1612
  });
1437
- const response = output.result.response ?? "";
1438
1613
 
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 },
1614
+ let latestRunId = "";
1615
+ let assistantResponse = "";
1616
+ const toolTimeline: string[] = [];
1617
+ const sections: Array<{ type: "text" | "tools"; content: string | string[] }> = [];
1618
+ let currentTools: string[] = [];
1619
+ let currentText = "";
1620
+ let checkpointedRun = false;
1621
+
1622
+ const buildMessages = (): Message[] => {
1623
+ const draftSections: Array<{ type: "text" | "tools"; content: string | string[] }> = [
1624
+ ...sections.map((s) => ({
1625
+ type: s.type,
1626
+ content: Array.isArray(s.content) ? [...s.content] : s.content,
1627
+ })),
1445
1628
  ];
1446
- await conversationStore.update(conversation);
1629
+ if (currentTools.length > 0) {
1630
+ draftSections.push({ type: "tools", content: [...currentTools] });
1631
+ }
1632
+ if (currentText.length > 0) {
1633
+ draftSections.push({ type: "text", content: currentText });
1634
+ }
1635
+ const hasDraftContent =
1636
+ assistantResponse.length > 0 || toolTimeline.length > 0 || draftSections.length > 0;
1637
+ if (!hasDraftContent) {
1638
+ return [...historyMessages, { role: "user" as const, content: userContent }];
1639
+ }
1640
+ return [
1641
+ ...historyMessages,
1642
+ { role: "user" as const, content: userContent },
1643
+ {
1644
+ role: "assistant" as const,
1645
+ content: assistantResponse,
1646
+ metadata:
1647
+ toolTimeline.length > 0 || draftSections.length > 0
1648
+ ? ({
1649
+ toolActivity: [...toolTimeline],
1650
+ sections: draftSections.length > 0 ? draftSections : undefined,
1651
+ } as Message["metadata"])
1652
+ : undefined,
1653
+ },
1654
+ ];
1655
+ };
1656
+
1657
+ const persistDraftAssistantTurn = async (): Promise<void> => {
1658
+ if (assistantResponse.length === 0 && toolTimeline.length === 0) return;
1659
+ await updateConversation((c) => {
1660
+ c.messages = buildMessages();
1661
+ });
1662
+ };
1663
+
1664
+ const runInput = {
1665
+ task: input.task,
1666
+ conversationId,
1667
+ messages: input.messages,
1668
+ files: input.files,
1669
+ parameters: input.metadata ? {
1670
+ __messaging_platform: input.metadata.platform,
1671
+ __messaging_sender_id: input.metadata.sender.id,
1672
+ __messaging_sender_name: input.metadata.sender.name ?? "",
1673
+ __messaging_thread_id: input.metadata.threadId,
1674
+ } : undefined,
1675
+ };
1676
+
1677
+ try {
1678
+ for await (const event of harness.runWithTelemetry(runInput)) {
1679
+ if (event.type === "run:started") {
1680
+ latestRunId = event.runId;
1681
+ runOwners.set(event.runId, "local-owner");
1682
+ runConversations.set(event.runId, conversationId);
1683
+ }
1684
+ if (event.type === "model:chunk") {
1685
+ if (currentTools.length > 0) {
1686
+ sections.push({ type: "tools", content: currentTools });
1687
+ currentTools = [];
1688
+ }
1689
+ assistantResponse += event.content;
1690
+ currentText += event.content;
1691
+ }
1692
+ if (event.type === "tool:started") {
1693
+ if (currentText.length > 0) {
1694
+ sections.push({ type: "text", content: currentText });
1695
+ currentText = "";
1696
+ }
1697
+ const toolText = `- start \`${event.tool}\``;
1698
+ toolTimeline.push(toolText);
1699
+ currentTools.push(toolText);
1700
+ }
1701
+ if (event.type === "tool:completed") {
1702
+ const toolText = `- done \`${event.tool}\` (${event.duration}ms)`;
1703
+ toolTimeline.push(toolText);
1704
+ currentTools.push(toolText);
1705
+ }
1706
+ if (event.type === "tool:error") {
1707
+ const toolText = `- error \`${event.tool}\`: ${event.error}`;
1708
+ toolTimeline.push(toolText);
1709
+ currentTools.push(toolText);
1710
+ }
1711
+ if (event.type === "step:completed") {
1712
+ await persistDraftAssistantTurn();
1713
+ }
1714
+ if (event.type === "tool:approval:required") {
1715
+ const toolText = `- approval required \`${event.tool}\``;
1716
+ toolTimeline.push(toolText);
1717
+ currentTools.push(toolText);
1718
+ await persistDraftAssistantTurn();
1719
+ }
1720
+ if (event.type === "tool:approval:checkpoint") {
1721
+ await updateConversation((c) => {
1722
+ c.messages = buildMessages();
1723
+ c.pendingApprovals = [{
1724
+ approvalId: event.approvalId,
1725
+ runId: latestRunId,
1726
+ tool: event.tool,
1727
+ toolCallId: event.toolCallId,
1728
+ input: event.input,
1729
+ checkpointMessages: event.checkpointMessages,
1730
+ baseMessageCount: historyMessages.length,
1731
+ pendingToolCalls: event.pendingToolCalls,
1732
+ }];
1733
+ });
1734
+ checkpointedRun = true;
1735
+ }
1736
+ if (
1737
+ event.type === "run:completed" &&
1738
+ assistantResponse.length === 0 &&
1739
+ event.result.response
1740
+ ) {
1741
+ assistantResponse = event.result.response;
1742
+ }
1743
+ if (event.type === "run:error") {
1744
+ assistantResponse = assistantResponse || `[Error: ${event.error.message}]`;
1745
+ }
1746
+ broadcastEvent(conversationId, event);
1747
+ }
1748
+ } catch (err) {
1749
+ console.error("[messaging-runner] run failed:", err instanceof Error ? err.message : err);
1750
+ assistantResponse = assistantResponse || `[Error: ${err instanceof Error ? err.message : "Unknown error"}]`;
1751
+ }
1752
+
1753
+ // Finalize sections (clear after pushing so buildMessages doesn't re-add)
1754
+ if (currentTools.length > 0) {
1755
+ sections.push({ type: "tools", content: currentTools });
1756
+ currentTools = [];
1447
1757
  }
1758
+ if (currentText.length > 0) {
1759
+ sections.push({ type: "text", content: currentText });
1760
+ currentText = "";
1761
+ }
1762
+
1763
+ if (!checkpointedRun) {
1764
+ await updateConversation((c) => {
1765
+ c.messages = buildMessages();
1766
+ c.runtimeRunId = latestRunId || c.runtimeRunId;
1767
+ c.pendingApprovals = [];
1768
+ });
1769
+ }
1770
+ finishConversationStream(conversationId);
1771
+ if (latestRunId) {
1772
+ runOwners.delete(latestRunId);
1773
+ runConversations.delete(latestRunId);
1774
+ }
1775
+
1776
+ console.log("[messaging-runner] run complete, response length:", assistantResponse.length);
1777
+ const response = assistantResponse;
1448
1778
 
1449
1779
  return { response };
1450
1780
  },
@@ -1475,6 +1805,7 @@ export const createRequestHandler = async (options?: {
1475
1805
  adapter,
1476
1806
  runner: messagingRunner,
1477
1807
  waitUntil: waitUntilHook,
1808
+ ownerId: "local-owner",
1478
1809
  });
1479
1810
  adapter.registerRoutes(messagingRouteRegistrar);
1480
1811
  try {
@@ -1486,6 +1817,37 @@ export const createRequestHandler = async (options?: {
1486
1817
  ` Slack messaging disabled: ${err instanceof Error ? err.message : String(err)}`,
1487
1818
  );
1488
1819
  }
1820
+ } else if (channelConfig.platform === "resend") {
1821
+ const adapter = new ResendAdapter({
1822
+ apiKeyEnv: channelConfig.apiKeyEnv,
1823
+ webhookSecretEnv: channelConfig.webhookSecretEnv,
1824
+ fromEnv: channelConfig.fromEnv,
1825
+ allowedSenders: channelConfig.allowedSenders,
1826
+ mode: channelConfig.mode,
1827
+ allowedRecipients: channelConfig.allowedRecipients,
1828
+ maxSendsPerRun: channelConfig.maxSendsPerRun,
1829
+ });
1830
+ const bridge = new AgentBridge({
1831
+ adapter,
1832
+ runner: messagingRunner,
1833
+ waitUntil: waitUntilHook,
1834
+ ownerId: "local-owner",
1835
+ });
1836
+ adapter.registerRoutes(messagingRouteRegistrar);
1837
+ try {
1838
+ await bridge.start();
1839
+ messagingBridges.push(bridge);
1840
+ const adapterTools = adapter.getToolDefinitions?.() ?? [];
1841
+ if (adapterTools.length > 0) {
1842
+ harness.registerTools(adapterTools);
1843
+ }
1844
+ const modeLabel = channelConfig.mode === "tool" ? "tool" : "auto-reply";
1845
+ console.log(` Resend email messaging enabled at /api/messaging/resend (mode: ${modeLabel})`);
1846
+ } catch (err) {
1847
+ console.warn(
1848
+ ` Resend email messaging disabled: ${err instanceof Error ? err.message : String(err)}`,
1849
+ );
1850
+ }
1489
1851
  }
1490
1852
  }
1491
1853
  }
@@ -1498,6 +1860,7 @@ export const createRequestHandler = async (options?: {
1498
1860
  const authRequired = config?.auth?.required ?? false;
1499
1861
  const requireAuth = authRequired && authToken.length > 0;
1500
1862
 
1863
+ const webUiEnabled = config?.webUi !== false;
1501
1864
  const isProduction = resolveHarnessEnvironment() === "production";
1502
1865
  const secureCookies = isProduction;
1503
1866
 
@@ -1523,41 +1886,52 @@ export const createRequestHandler = async (options?: {
1523
1886
  }
1524
1887
  const [pathname] = request.url.split("?");
1525
1888
 
1526
- if (request.method === "GET" && (pathname === "/" || pathname.startsWith("/c/"))) {
1527
- writeHtml(response, 200, renderWebUiHtml({ agentName }));
1528
- return;
1529
- }
1889
+ if (webUiEnabled) {
1890
+ if (request.method === "GET" && (pathname === "/" || pathname.startsWith("/c/"))) {
1891
+ writeHtml(response, 200, renderWebUiHtml({ agentName }));
1892
+ return;
1893
+ }
1530
1894
 
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
- }
1895
+ if (pathname === "/manifest.json" && request.method === "GET") {
1896
+ response.writeHead(200, { "Content-Type": "application/manifest+json" });
1897
+ response.end(renderManifest({ agentName }));
1898
+ return;
1899
+ }
1536
1900
 
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;
1901
+ if (pathname === "/sw.js" && request.method === "GET") {
1902
+ response.writeHead(200, {
1903
+ "Content-Type": "application/javascript",
1904
+ "Service-Worker-Allowed": "/",
1905
+ });
1906
+ response.end(renderServiceWorker());
1907
+ return;
1908
+ }
1909
+
1910
+ if (pathname === "/icon.svg" && request.method === "GET") {
1911
+ response.writeHead(200, { "Content-Type": "image/svg+xml" });
1912
+ response.end(renderIconSvg({ agentName }));
1913
+ return;
1914
+ }
1915
+
1916
+ if ((pathname === "/icon-192.png" || pathname === "/icon-512.png") && request.method === "GET") {
1917
+ response.writeHead(302, { Location: "/icon.svg" });
1918
+ response.end();
1919
+ return;
1920
+ }
1544
1921
  }
1545
1922
 
1546
- if (pathname === "/icon.svg" && request.method === "GET") {
1547
- response.writeHead(200, { "Content-Type": "image/svg+xml" });
1548
- response.end(renderIconSvg({ agentName }));
1923
+ if (pathname === "/health" && request.method === "GET") {
1924
+ writeJson(response, 200, { status: "ok" });
1549
1925
  return;
1550
1926
  }
1551
1927
 
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();
1928
+ if (pathname === "/api/openapi.json" && request.method === "GET") {
1929
+ writeJson(response, 200, buildOpenApiSpec({ agentName }));
1556
1930
  return;
1557
1931
  }
1558
1932
 
1559
- if (pathname === "/health" && request.method === "GET") {
1560
- writeJson(response, 200, { status: "ok" });
1933
+ if (pathname === "/api/docs" && request.method === "GET") {
1934
+ writeHtml(response, 200, renderApiDocsHtml("/api/openapi.json"));
1561
1935
  return;
1562
1936
  }
1563
1937
 
@@ -1696,6 +2070,7 @@ export const createRequestHandler = async (options?: {
1696
2070
  createdAt: conversation.createdAt,
1697
2071
  updatedAt: conversation.updatedAt,
1698
2072
  messageCount: conversation.messages.length,
2073
+ hasPendingApprovals: Array.isArray(conversation.pendingApprovals) && conversation.pendingApprovals.length > 0,
1699
2074
  })),
1700
2075
  });
1701
2076
  return;
@@ -1721,40 +2096,93 @@ export const createRequestHandler = async (options?: {
1721
2096
  const approvalMatch = pathname.match(/^\/api\/approvals\/([^/]+)$/);
1722
2097
  if (approvalMatch && request.method === "POST") {
1723
2098
  const approvalId = decodeURIComponent(approvalMatch[1] ?? "");
1724
- const pending = pendingApprovals.get(approvalId);
1725
- if (!pending || pending.ownerId !== ownerId) {
1726
- // If the server restarted, an old pending approval can remain in
1727
- // conversation history without an active resolver. Prune stale entries.
1728
- const conversations = await conversationStore.list(ownerId);
1729
- let prunedStale = false;
1730
- for (const conversation of conversations) {
1731
- if (!Array.isArray(conversation.pendingApprovals)) {
1732
- continue;
1733
- }
1734
- const next = conversation.pendingApprovals.filter(
1735
- (approval) => approval.approvalId !== approvalId,
1736
- );
1737
- if (next.length !== conversation.pendingApprovals.length) {
1738
- conversation.pendingApprovals = next;
1739
- await conversationStore.update(conversation);
1740
- prunedStale = true;
1741
- }
2099
+ const body = (await readRequestBody(request)) as { approved?: boolean };
2100
+ const approved = body.approved === true;
2101
+
2102
+ // Find the approval in the conversation store (checkpoint-based flow)
2103
+ const conversations = await conversationStore.list(ownerId);
2104
+ let foundConversation: Conversation | undefined;
2105
+ let foundApproval: NonNullable<Conversation["pendingApprovals"]>[number] | undefined;
2106
+ for (const conv of conversations) {
2107
+ if (!Array.isArray(conv.pendingApprovals)) continue;
2108
+ const match = conv.pendingApprovals.find(a => a.approvalId === approvalId);
2109
+ if (match) {
2110
+ foundConversation = conv;
2111
+ foundApproval = match;
2112
+ break;
1742
2113
  }
2114
+ }
2115
+
2116
+ if (!foundConversation || !foundApproval) {
1743
2117
  writeJson(response, 404, {
1744
2118
  code: "APPROVAL_NOT_FOUND",
1745
- message: prunedStale
1746
- ? "Approval request is no longer active"
1747
- : "Approval request not found",
2119
+ message: "Approval request not found",
1748
2120
  });
1749
2121
  return;
1750
2122
  }
1751
- const body = (await readRequestBody(request)) as { approved?: boolean };
1752
- const approved = body.approved === true;
1753
- pendingApprovals.delete(approvalId);
1754
- if (pending.conversationId) {
1755
- await persistConversationPendingApprovals(pending.conversationId);
2123
+
2124
+ const conversationId = foundConversation.conversationId;
2125
+
2126
+ if (!foundApproval.checkpointMessages || !foundApproval.toolCallId) {
2127
+ // Legacy approval without checkpoint data — cannot resume
2128
+ foundConversation.pendingApprovals = (foundConversation.pendingApprovals ?? [])
2129
+ .filter(a => a.approvalId !== approvalId);
2130
+ await conversationStore.update(foundConversation);
2131
+ writeJson(response, 404, {
2132
+ code: "APPROVAL_NOT_FOUND",
2133
+ message: "Approval request is no longer active (no checkpoint data)",
2134
+ });
2135
+ return;
1756
2136
  }
1757
- pending.resolve(approved);
2137
+
2138
+ // Clear this approval from the conversation before resuming
2139
+ foundConversation.pendingApprovals = (foundConversation.pendingApprovals ?? [])
2140
+ .filter(a => a.approvalId !== approvalId);
2141
+ await conversationStore.update(foundConversation);
2142
+
2143
+ // Initialize the event stream so the web UI can connect immediately
2144
+ broadcastEvent(conversationId,
2145
+ approved
2146
+ ? { type: "tool:approval:granted", approvalId }
2147
+ : { type: "tool:approval:denied", approvalId },
2148
+ );
2149
+
2150
+ // Resume the run asynchronously (tool execution + continuation)
2151
+ void (async () => {
2152
+ let toolResults: Array<{ callId: string; toolName: string; result?: unknown; error?: string }>;
2153
+ if (approved) {
2154
+ const toolContext = {
2155
+ runId: foundApproval.runId,
2156
+ agentId: identity.id,
2157
+ step: 0,
2158
+ workingDir,
2159
+ parameters: {},
2160
+ };
2161
+ const execResults = await harness.executeTools(
2162
+ [{ id: foundApproval.toolCallId!, name: foundApproval.tool, input: foundApproval.input }],
2163
+ toolContext,
2164
+ );
2165
+ toolResults = execResults.map(r => ({
2166
+ callId: r.callId,
2167
+ toolName: r.tool,
2168
+ result: r.output,
2169
+ error: r.error,
2170
+ }));
2171
+ } else {
2172
+ toolResults = [{
2173
+ callId: foundApproval.toolCallId!,
2174
+ toolName: foundApproval.tool,
2175
+ error: "Tool execution denied by user",
2176
+ }];
2177
+ }
2178
+ await resumeRunFromCheckpoint(
2179
+ conversationId,
2180
+ foundConversation!,
2181
+ foundApproval!,
2182
+ toolResults,
2183
+ );
2184
+ })();
2185
+
1758
2186
  writeJson(response, 200, { ok: true, approvalId, approved });
1759
2187
  return;
1760
2188
  }
@@ -1779,18 +2207,19 @@ export const createRequestHandler = async (options?: {
1779
2207
  });
1780
2208
  const stream = conversationEventStreams.get(conversationId);
1781
2209
  if (!stream) {
1782
- // No active run — close immediately
1783
2210
  response.write("event: stream:end\ndata: {}\n\n");
1784
2211
  response.end();
1785
2212
  return;
1786
2213
  }
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;
2214
+ const liveOnly = (request.url ?? "").includes("live_only=true");
2215
+ if (!liveOnly) {
2216
+ for (const bufferedEvent of stream.buffer) {
2217
+ try {
2218
+ response.write(formatSseEvent(bufferedEvent));
2219
+ } catch {
2220
+ response.end();
2221
+ return;
2222
+ }
1794
2223
  }
1795
2224
  }
1796
2225
  if (stream.finished) {
@@ -1819,33 +2248,21 @@ export const createRequestHandler = async (options?: {
1819
2248
  }
1820
2249
  if (request.method === "GET") {
1821
2250
  const storedPending = Array.isArray(conversation.pendingApprovals)
1822
- ? conversation.pendingApprovals
2251
+ ? conversation.pendingApprovals.map(a => ({
2252
+ approvalId: a.approvalId,
2253
+ runId: a.runId,
2254
+ tool: a.tool,
2255
+ input: a.input,
2256
+ }))
1823
2257
  : [];
1824
- const livePending = Array.from(pendingApprovals.entries())
1825
- .filter(
1826
- ([, pending]) =>
1827
- pending.ownerId === ownerId && pending.conversationId === conversationId,
1828
- )
1829
- .map(([approvalId, pending]) => ({
1830
- approvalId,
1831
- runId: pending.runId,
1832
- tool: pending.tool,
1833
- input: pending.input,
1834
- }));
1835
- const mergedPendingById = new Map<string, (typeof livePending)[number]>();
1836
- for (const approval of storedPending) {
1837
- if (approval && typeof approval.approvalId === "string") {
1838
- mergedPendingById.set(approval.approvalId, approval);
1839
- }
1840
- }
1841
- for (const approval of livePending) {
1842
- mergedPendingById.set(approval.approvalId, approval);
1843
- }
2258
+ const activeStream = conversationEventStreams.get(conversationId);
2259
+ const hasActiveRun = !!activeStream && !activeStream.finished;
1844
2260
  writeJson(response, 200, {
1845
2261
  conversation: {
1846
2262
  ...conversation,
1847
- pendingApprovals: Array.from(mergedPendingById.values()),
2263
+ pendingApprovals: storedPending,
1848
2264
  },
2265
+ hasActiveRun,
1849
2266
  });
1850
2267
  return;
1851
2268
  }
@@ -2029,6 +2446,7 @@ export const createRequestHandler = async (options?: {
2029
2446
  let currentText = "";
2030
2447
  let currentTools: string[] = [];
2031
2448
  let runCancelled = false;
2449
+ let checkpointedRun = false;
2032
2450
  let userContent: Message["content"] = messageText;
2033
2451
  if (files.length > 0) {
2034
2452
  try {
@@ -2124,6 +2542,7 @@ export const createRequestHandler = async (options?: {
2124
2542
 
2125
2543
  for await (const event of harness.runWithTelemetry({
2126
2544
  task: messageText,
2545
+ conversationId,
2127
2546
  parameters: {
2128
2547
  ...(bodyParameters ?? {}),
2129
2548
  __conversationRecallCorpus: recallCorpus,
@@ -2183,17 +2602,40 @@ export const createRequestHandler = async (options?: {
2183
2602
  currentTools.push(toolText);
2184
2603
  await persistDraftAssistantTurn();
2185
2604
  }
2186
- if (event.type === "tool:approval:granted") {
2187
- const toolText = `- approval granted (${event.approvalId})`;
2188
- toolTimeline.push(toolText);
2189
- currentTools.push(toolText);
2190
- await persistDraftAssistantTurn();
2191
- }
2192
- if (event.type === "tool:approval:denied") {
2193
- const toolText = `- approval denied (${event.approvalId})`;
2194
- toolTimeline.push(toolText);
2195
- currentTools.push(toolText);
2196
- await persistDraftAssistantTurn();
2605
+ if (event.type === "tool:approval:checkpoint") {
2606
+ const checkpointSections = [...sections];
2607
+ if (currentTools.length > 0) {
2608
+ checkpointSections.push({ type: "tools", content: [...currentTools] });
2609
+ }
2610
+ if (currentText.length > 0) {
2611
+ checkpointSections.push({ type: "text", content: currentText });
2612
+ }
2613
+ conversation.messages = [
2614
+ ...historyMessages,
2615
+ { role: "user", content: userContent },
2616
+ ...(assistantResponse.length > 0 || toolTimeline.length > 0 || checkpointSections.length > 0
2617
+ ? [{
2618
+ role: "assistant" as const,
2619
+ content: assistantResponse,
2620
+ metadata: (toolTimeline.length > 0 || checkpointSections.length > 0
2621
+ ? { toolActivity: [...toolTimeline], sections: checkpointSections.length > 0 ? checkpointSections : undefined }
2622
+ : undefined) as Message["metadata"],
2623
+ }]
2624
+ : []),
2625
+ ];
2626
+ conversation.pendingApprovals = [{
2627
+ approvalId: event.approvalId,
2628
+ runId: latestRunId,
2629
+ tool: event.tool,
2630
+ toolCallId: event.toolCallId,
2631
+ input: event.input,
2632
+ checkpointMessages: event.checkpointMessages,
2633
+ baseMessageCount: historyMessages.length,
2634
+ pendingToolCalls: event.pendingToolCalls,
2635
+ }];
2636
+ conversation.updatedAt = Date.now();
2637
+ await conversationStore.update(conversation);
2638
+ checkpointedRun = true;
2197
2639
  }
2198
2640
  if (
2199
2641
  event.type === "run:completed" &&
@@ -2218,29 +2660,31 @@ export const createRequestHandler = async (options?: {
2218
2660
  if (currentText.length > 0) {
2219
2661
  sections.push({ type: "text", content: currentText });
2220
2662
  }
2221
- const hasAssistantContent =
2222
- assistantResponse.length > 0 || toolTimeline.length > 0 || sections.length > 0;
2223
- conversation.messages = hasAssistantContent
2224
- ? [
2225
- ...historyMessages,
2226
- { role: "user", content: userContent },
2227
- {
2228
- role: "assistant",
2229
- content: assistantResponse,
2230
- metadata:
2231
- toolTimeline.length > 0 || sections.length > 0
2232
- ? ({
2233
- toolActivity: toolTimeline,
2234
- sections: sections.length > 0 ? sections : undefined,
2235
- } as Message["metadata"])
2236
- : undefined,
2237
- },
2238
- ]
2239
- : [...historyMessages, { role: "user", content: userContent }];
2240
- conversation.runtimeRunId = latestRunId || conversation.runtimeRunId;
2241
- conversation.pendingApprovals = [];
2242
- conversation.updatedAt = Date.now();
2243
- await conversationStore.update(conversation);
2663
+ if (!checkpointedRun) {
2664
+ const hasAssistantContent =
2665
+ assistantResponse.length > 0 || toolTimeline.length > 0 || sections.length > 0;
2666
+ conversation.messages = hasAssistantContent
2667
+ ? [
2668
+ ...historyMessages,
2669
+ { role: "user", content: userContent },
2670
+ {
2671
+ role: "assistant",
2672
+ content: assistantResponse,
2673
+ metadata:
2674
+ toolTimeline.length > 0 || sections.length > 0
2675
+ ? ({
2676
+ toolActivity: toolTimeline,
2677
+ sections: sections.length > 0 ? sections : undefined,
2678
+ } as Message["metadata"])
2679
+ : undefined,
2680
+ },
2681
+ ]
2682
+ : [...historyMessages, { role: "user", content: userContent }];
2683
+ conversation.runtimeRunId = latestRunId || conversation.runtimeRunId;
2684
+ conversation.pendingApprovals = [];
2685
+ conversation.updatedAt = Date.now();
2686
+ await conversationStore.update(conversation);
2687
+ }
2244
2688
  } catch (error) {
2245
2689
  if (abortController.signal.aborted || runCancelled) {
2246
2690
  const fallbackSections = [...sections];
@@ -2318,7 +2762,6 @@ export const createRequestHandler = async (options?: {
2318
2762
  activeConversationRuns.delete(conversationId);
2319
2763
  }
2320
2764
  finishConversationStream(conversationId);
2321
- await persistConversationPendingApprovals(conversationId);
2322
2765
  if (latestRunId) {
2323
2766
  runOwners.delete(latestRunId);
2324
2767
  runConversations.delete(latestRunId);
@@ -2403,6 +2846,7 @@ export const createRequestHandler = async (options?: {
2403
2846
 
2404
2847
  for await (const event of harness.runWithTelemetry({
2405
2848
  task: cronJob.task,
2849
+ conversationId: conversation.conversationId,
2406
2850
  parameters: { __activeConversationId: conversation.conversationId },
2407
2851
  messages: historyMessages,
2408
2852
  abortSignal: abortController.signal,
@@ -2589,6 +3033,7 @@ export const startDevServer = async (
2589
3033
  let currentText = "";
2590
3034
  for await (const event of harness.runWithTelemetry({
2591
3035
  task: config.task,
3036
+ conversationId: conversation.conversationId,
2592
3037
  parameters: { __activeConversationId: conversation.conversationId },
2593
3038
  messages: [],
2594
3039
  })) {
@@ -2775,44 +3220,10 @@ export const runInteractive = async (
2775
3220
  dotenv.config({ path: resolve(workingDir, ".env") });
2776
3221
  const config = await loadPonchoConfig(workingDir);
2777
3222
 
2778
- // Approval bridge: the harness calls this handler which creates a pending
2779
- // promise. The Ink UI picks up the pending request and shows a Y/N prompt.
2780
- // The user's response resolves the promise.
2781
- type ApprovalRequest = {
2782
- tool: string;
2783
- input: Record<string, unknown>;
2784
- approvalId: string;
2785
- resolve: (approved: boolean) => void;
2786
- };
2787
- let pendingApproval: ApprovalRequest | null = null;
2788
- let onApprovalRequest: ((req: ApprovalRequest) => void) | null = null;
2789
-
2790
- const approvalHandler = async (request: {
2791
- tool: string;
2792
- input: Record<string, unknown>;
2793
- runId: string;
2794
- step: number;
2795
- approvalId: string;
2796
- }): Promise<boolean> => {
2797
- return new Promise<boolean>((resolveApproval) => {
2798
- const req: ApprovalRequest = {
2799
- tool: request.tool,
2800
- input: request.input,
2801
- approvalId: request.approvalId,
2802
- resolve: resolveApproval,
2803
- };
2804
- pendingApproval = req;
2805
- if (onApprovalRequest) {
2806
- onApprovalRequest(req);
2807
- }
2808
- });
2809
- };
2810
-
2811
3223
  const uploadStore = await createUploadStore(config?.uploads, workingDir);
2812
3224
  const harness = new AgentHarness({
2813
3225
  workingDir,
2814
3226
  environment: resolveHarnessEnvironment(),
2815
- approvalHandler,
2816
3227
  uploadStore,
2817
3228
  });
2818
3229
  await harness.initialize();
@@ -2826,7 +3237,6 @@ export const runInteractive = async (
2826
3237
  workingDir: string;
2827
3238
  config?: PonchoConfig;
2828
3239
  conversationStore: ConversationStore;
2829
- onSetApprovalCallback?: (cb: (req: ApprovalRequest) => void) => void;
2830
3240
  }) => Promise<void>
2831
3241
  )({
2832
3242
  harness,
@@ -2837,13 +3247,6 @@ export const runInteractive = async (
2837
3247
  workingDir,
2838
3248
  agentId: identity.id,
2839
3249
  }),
2840
- onSetApprovalCallback: (cb: (req: ApprovalRequest) => void) => {
2841
- onApprovalRequest = cb;
2842
- // If there's already a pending request, fire it immediately
2843
- if (pendingApproval) {
2844
- cb(pendingApproval);
2845
- }
2846
- },
2847
3250
  });
2848
3251
  } finally {
2849
3252
  await harness.shutdown();