@poncho-ai/cli 0.11.1 → 0.13.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
@@ -1,6 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { access, cp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
3
- import { existsSync } from "node:fs";
3
+ import { existsSync, watch as fsWatch } from "node:fs";
4
4
  import {
5
5
  createServer,
6
6
  type IncomingMessage,
@@ -20,13 +20,22 @@ import {
20
20
  ensureAgentIdentity,
21
21
  generateAgentId,
22
22
  loadPonchoConfig,
23
+ parseAgentMarkdown,
23
24
  resolveStateConfig,
25
+ type CronJobConfig,
24
26
  type PonchoConfig,
25
27
  type ConversationStore,
26
28
  type UploadStore,
27
29
  } from "@poncho-ai/harness";
28
30
  import type { AgentEvent, FileInput, Message, RunInput } from "@poncho-ai/sdk";
29
31
  import { getTextContent } from "@poncho-ai/sdk";
32
+ import {
33
+ AgentBridge,
34
+ SlackAdapter,
35
+ type AgentRunner,
36
+ type MessagingAdapter,
37
+ type RouteRegistrar,
38
+ } from "@poncho-ai/messaging";
30
39
  import Busboy from "busboy";
31
40
  import { Command } from "commander";
32
41
  import dotenv from "dotenv";
@@ -549,6 +558,40 @@ ${name}/
549
558
  └── fetch-page.ts
550
559
  \`\`\`
551
560
 
561
+ ## Cron Jobs
562
+
563
+ Define scheduled tasks in \`AGENT.md\` frontmatter:
564
+
565
+ \`\`\`yaml
566
+ cron:
567
+ daily-report:
568
+ schedule: "0 9 * * *"
569
+ task: "Generate the daily sales report"
570
+ \`\`\`
571
+
572
+ - \`poncho dev\`: jobs run via an in-process scheduler.
573
+ - \`poncho build vercel\`: generates \`vercel.json\` cron entries.
574
+ - Docker/Fly.io: scheduler runs automatically.
575
+ - Trigger manually: \`curl http://localhost:3000/api/cron/daily-report\`
576
+
577
+ ## Messaging (Slack)
578
+
579
+ Connect your agent to Slack so it responds to @mentions:
580
+
581
+ 1. Create a Slack App at [api.slack.com/apps](https://api.slack.com/apps)
582
+ 2. Add Bot Token Scopes: \`app_mentions:read\`, \`chat:write\`, \`reactions:write\`
583
+ 3. Enable Event Subscriptions, set Request URL to \`https://<your-url>/api/messaging/slack\`, subscribe to \`app_mention\`
584
+ 4. Install to workspace, copy Bot Token and Signing Secret
585
+ 5. Set env vars:
586
+ \`\`\`
587
+ SLACK_BOT_TOKEN=xoxb-...
588
+ SLACK_SIGNING_SECRET=...
589
+ \`\`\`
590
+ 6. Add to \`poncho.config.js\`:
591
+ \`\`\`javascript
592
+ messaging: [{ platform: 'slack' }]
593
+ \`\`\`
594
+
552
595
  ## Deployment
553
596
 
554
597
  \`\`\`bash
@@ -796,6 +839,54 @@ const ensureRuntimeCliDependency = async (
796
839
  return { paths: [relative(projectDir, packageJsonPath)], addedDeps };
797
840
  };
798
841
 
842
+ const checkVercelCronDrift = async (projectDir: string): Promise<void> => {
843
+ const vercelJsonPath = resolve(projectDir, "vercel.json");
844
+ try {
845
+ await access(vercelJsonPath);
846
+ } catch {
847
+ return;
848
+ }
849
+ let agentCrons: Record<string, CronJobConfig> = {};
850
+ try {
851
+ const agentMd = await readFile(resolve(projectDir, "AGENT.md"), "utf8");
852
+ const parsed = parseAgentMarkdown(agentMd);
853
+ agentCrons = parsed.frontmatter.cron ?? {};
854
+ } catch {
855
+ return;
856
+ }
857
+ let vercelCrons: Array<{ path: string; schedule: string }> = [];
858
+ try {
859
+ const raw = await readFile(vercelJsonPath, "utf8");
860
+ const vercelConfig = JSON.parse(raw) as { crons?: Array<{ path: string; schedule: string }> };
861
+ vercelCrons = vercelConfig.crons ?? [];
862
+ } catch {
863
+ return;
864
+ }
865
+ const vercelCronMap = new Map(
866
+ vercelCrons
867
+ .filter((c) => c.path.startsWith("/api/cron/"))
868
+ .map((c) => [decodeURIComponent(c.path.replace("/api/cron/", "")), c.schedule]),
869
+ );
870
+ const diffs: string[] = [];
871
+ for (const [jobName, job] of Object.entries(agentCrons)) {
872
+ const existing = vercelCronMap.get(jobName);
873
+ if (!existing) {
874
+ diffs.push(` + missing job "${jobName}" (${job.schedule})`);
875
+ } else if (existing !== job.schedule) {
876
+ diffs.push(` ~ "${jobName}" schedule changed: "${existing}" → "${job.schedule}"`);
877
+ }
878
+ vercelCronMap.delete(jobName);
879
+ }
880
+ for (const [jobName, schedule] of vercelCronMap) {
881
+ diffs.push(` - removed job "${jobName}" (${schedule})`);
882
+ }
883
+ if (diffs.length > 0) {
884
+ process.stderr.write(
885
+ `\u26A0 vercel.json crons are out of sync with AGENT.md:\n${diffs.join("\n")}\n Run \`poncho build vercel --force\` to update.\n\n`,
886
+ );
887
+ }
888
+ };
889
+
799
890
  const scaffoldDeployTarget = async (
800
891
  projectDir: string,
801
892
  target: DeployScaffoldTarget,
@@ -835,22 +926,37 @@ export default async function handler(req, res) {
835
926
  { force: options?.force, writtenPaths, baseDir: projectDir },
836
927
  );
837
928
  const vercelConfigPath = resolve(projectDir, "vercel.json");
929
+ let vercelCrons: Array<{ path: string; schedule: string }> | undefined;
930
+ try {
931
+ const agentMd = await readFile(resolve(projectDir, "AGENT.md"), "utf8");
932
+ const parsed = parseAgentMarkdown(agentMd);
933
+ if (parsed.frontmatter.cron) {
934
+ vercelCrons = Object.entries(parsed.frontmatter.cron).map(
935
+ ([jobName, job]) => ({
936
+ path: `/api/cron/${encodeURIComponent(jobName)}`,
937
+ schedule: job.schedule,
938
+ }),
939
+ );
940
+ }
941
+ } catch {
942
+ // AGENT.md may not exist yet during init; skip cron generation
943
+ }
944
+ const vercelConfig: Record<string, unknown> = {
945
+ version: 2,
946
+ functions: {
947
+ "api/index.mjs": {
948
+ includeFiles:
949
+ "{AGENT.md,poncho.config.js,skills/**,tests/**,node_modules/.pnpm/marked@*/node_modules/marked/lib/marked.umd.js}",
950
+ },
951
+ },
952
+ routes: [{ src: "/(.*)", dest: "/api/index.mjs" }],
953
+ };
954
+ if (vercelCrons && vercelCrons.length > 0) {
955
+ vercelConfig.crons = vercelCrons;
956
+ }
838
957
  await writeScaffoldFile(
839
958
  vercelConfigPath,
840
- `${JSON.stringify(
841
- {
842
- version: 2,
843
- functions: {
844
- "api/index.mjs": {
845
- includeFiles:
846
- "{AGENT.md,poncho.config.js,skills/**,tests/**,node_modules/.pnpm/marked@*/node_modules/marked/lib/marked.umd.js}",
847
- },
848
- },
849
- routes: [{ src: "/(.*)", dest: "/api/index.mjs" }],
850
- },
851
- null,
852
- 2,
853
- )}\n`,
959
+ `${JSON.stringify(vercelConfig, null, 2)}\n`,
854
960
  { force: options?.force, writtenPaths, baseDir: projectDir },
855
961
  );
856
962
  } else if (target === "docker") {
@@ -892,6 +998,11 @@ export const handler = async (event = {}) => {
892
998
  });
893
999
  return { statusCode: 200, headers: { "content-type": "application/json" }, body };
894
1000
  };
1001
+
1002
+ // Cron jobs: use AWS EventBridge (CloudWatch Events) to trigger scheduled invocations.
1003
+ // Create a rule for each cron job defined in AGENT.md that sends a GET request to:
1004
+ // /api/cron/<jobName>
1005
+ // Include the Authorization header with your PONCHO_AUTH_TOKEN as a Bearer token.
895
1006
  `,
896
1007
  { force: options?.force, writtenPaths, baseDir: projectDir },
897
1008
  );
@@ -1131,10 +1242,14 @@ export const updateAgentGuidance = async (workingDir: string): Promise<boolean>
1131
1242
  const formatSseEvent = (event: AgentEvent): string =>
1132
1243
  `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`;
1133
1244
 
1134
- export type RequestHandler = (
1245
+ export type RequestHandler = ((
1135
1246
  request: IncomingMessage,
1136
1247
  response: ServerResponse,
1137
- ) => Promise<void>;
1248
+ ) => Promise<void>) & {
1249
+ _harness?: AgentHarness;
1250
+ _cronJobs?: Record<string, CronJobConfig>;
1251
+ _conversationStore?: ConversationStore;
1252
+ };
1138
1253
 
1139
1254
  export const createRequestHandler = async (options?: {
1140
1255
  workingDir?: string;
@@ -1145,6 +1260,7 @@ export const createRequestHandler = async (options?: {
1145
1260
  let agentName = "Agent";
1146
1261
  let agentModelProvider = "anthropic";
1147
1262
  let agentModelName = "claude-opus-4-5";
1263
+ let cronJobs: Record<string, CronJobConfig> = {};
1148
1264
  try {
1149
1265
  const agentMd = await readFile(resolve(workingDir, "AGENT.md"), "utf8");
1150
1266
  const nameMatch = agentMd.match(/^name:\s*(.+)$/m);
@@ -1159,6 +1275,12 @@ export const createRequestHandler = async (options?: {
1159
1275
  if (modelMatch?.[1]) {
1160
1276
  agentModelName = modelMatch[1].trim().replace(/^["']|["']$/g, "");
1161
1277
  }
1278
+ try {
1279
+ const parsed = parseAgentMarkdown(agentMd);
1280
+ cronJobs = parsed.frontmatter.cron ?? {};
1281
+ } catch {
1282
+ // Cron parsing failure should not block the server
1283
+ }
1162
1284
  } catch {}
1163
1285
  const runOwners = new Map<string, string>();
1164
1286
  const runConversations = new Map<string, string>();
@@ -1274,6 +1396,100 @@ export const createRequestHandler = async (options?: {
1274
1396
  workingDir,
1275
1397
  agentId: identity.id,
1276
1398
  });
1399
+ // ---------------------------------------------------------------------------
1400
+ // Messaging adapters (Slack, etc.) — routes bypass Poncho auth; each
1401
+ // adapter handles its own request verification (e.g. Slack signing secret).
1402
+ // ---------------------------------------------------------------------------
1403
+ const messagingRoutes = new Map<string, Map<string, (req: IncomingMessage, res: ServerResponse) => Promise<void>>>();
1404
+ const messagingRouteRegistrar: RouteRegistrar = (method, path, routeHandler) => {
1405
+ let byMethod = messagingRoutes.get(path);
1406
+ if (!byMethod) {
1407
+ byMethod = new Map();
1408
+ messagingRoutes.set(path, byMethod);
1409
+ }
1410
+ byMethod.set(method, routeHandler);
1411
+ };
1412
+
1413
+ const messagingRunner: AgentRunner = {
1414
+ async getOrCreateConversation(conversationId, meta) {
1415
+ const existing = await conversationStore.get(conversationId);
1416
+ if (existing) {
1417
+ return { messages: existing.messages };
1418
+ }
1419
+ const now = Date.now();
1420
+ const conversation = {
1421
+ conversationId,
1422
+ title: meta.title ?? `${meta.platform} thread`,
1423
+ messages: [] as Message[],
1424
+ ownerId: meta.ownerId,
1425
+ tenantId: null,
1426
+ createdAt: now,
1427
+ updatedAt: now,
1428
+ };
1429
+ await conversationStore.update(conversation);
1430
+ return { messages: [] };
1431
+ },
1432
+ async run(conversationId, input) {
1433
+ const output = await harness.runToCompletion({
1434
+ task: input.task,
1435
+ messages: input.messages,
1436
+ });
1437
+ const response = output.result.response ?? "";
1438
+
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 },
1445
+ ];
1446
+ await conversationStore.update(conversation);
1447
+ }
1448
+
1449
+ return { response };
1450
+ },
1451
+ };
1452
+
1453
+ const messagingBridges: AgentBridge[] = [];
1454
+ if (config?.messaging && config.messaging.length > 0) {
1455
+ let waitUntilHook: ((promise: Promise<unknown>) => void) | undefined;
1456
+ if (process.env.VERCEL) {
1457
+ try {
1458
+ // Dynamic require via variable so TypeScript doesn't attempt static
1459
+ // resolution of @vercel/functions (only present in Vercel deployments).
1460
+ const modName = "@vercel/functions";
1461
+ const mod = await import(/* webpackIgnore: true */ modName);
1462
+ waitUntilHook = mod.waitUntil;
1463
+ } catch {
1464
+ // @vercel/functions not installed -- fall through to no-op.
1465
+ }
1466
+ }
1467
+
1468
+ for (const channelConfig of config.messaging) {
1469
+ if (channelConfig.platform === "slack") {
1470
+ const adapter = new SlackAdapter({
1471
+ botTokenEnv: channelConfig.botTokenEnv,
1472
+ signingSecretEnv: channelConfig.signingSecretEnv,
1473
+ });
1474
+ const bridge = new AgentBridge({
1475
+ adapter,
1476
+ runner: messagingRunner,
1477
+ waitUntil: waitUntilHook,
1478
+ });
1479
+ adapter.registerRoutes(messagingRouteRegistrar);
1480
+ try {
1481
+ await bridge.start();
1482
+ messagingBridges.push(bridge);
1483
+ console.log(` Slack messaging enabled at /api/messaging/slack`);
1484
+ } catch (err) {
1485
+ console.warn(
1486
+ ` Slack messaging disabled: ${err instanceof Error ? err.message : String(err)}`,
1487
+ );
1488
+ }
1489
+ }
1490
+ }
1491
+ }
1492
+
1277
1493
  const sessionStore = new SessionStore();
1278
1494
  const loginRateLimiter = new LoginRateLimiter();
1279
1495
 
@@ -1300,7 +1516,7 @@ export const createRequestHandler = async (options?: {
1300
1516
  return verifyPassphrase(match[1], authToken);
1301
1517
  };
1302
1518
 
1303
- return async (request: IncomingMessage, response: ServerResponse) => {
1519
+ const handler: RequestHandler = async (request: IncomingMessage, response: ServerResponse) => {
1304
1520
  if (!request.url || !request.method) {
1305
1521
  writeJson(response, 404, { error: "Not found" });
1306
1522
  return;
@@ -1345,6 +1561,17 @@ export const createRequestHandler = async (options?: {
1345
1561
  return;
1346
1562
  }
1347
1563
 
1564
+ // Messaging adapter routes bypass Poncho auth (they verify requests
1565
+ // using platform-specific mechanisms, e.g. Slack signing secret).
1566
+ const messagingByMethod = messagingRoutes.get(pathname ?? "");
1567
+ if (messagingByMethod) {
1568
+ const routeHandler = messagingByMethod.get(request.method ?? "");
1569
+ if (routeHandler) {
1570
+ await routeHandler(request, response);
1571
+ return;
1572
+ }
1573
+ }
1574
+
1348
1575
  const cookies = parseCookies(request);
1349
1576
  const sessionId = cookies.poncho_session;
1350
1577
  const session = sessionId ? sessionStore.get(sessionId) : undefined;
@@ -2105,14 +2332,214 @@ export const createRequestHandler = async (options?: {
2105
2332
  return;
2106
2333
  }
2107
2334
 
2335
+ // ── Cron job endpoint ──────────────────────────────────────────
2336
+ const cronMatch = pathname.match(/^\/api\/cron\/([^/]+)$/);
2337
+ if (cronMatch && (request.method === "GET" || request.method === "POST")) {
2338
+ const jobName = decodeURIComponent(cronMatch[1] ?? "");
2339
+ const cronJob = cronJobs[jobName];
2340
+ if (!cronJob) {
2341
+ writeJson(response, 404, {
2342
+ code: "CRON_JOB_NOT_FOUND",
2343
+ message: `Cron job "${jobName}" is not defined in AGENT.md`,
2344
+ });
2345
+ return;
2346
+ }
2347
+
2348
+ const urlObj = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
2349
+ const continueConversationId = urlObj.searchParams.get("continue");
2350
+ const continuationCount = Number(urlObj.searchParams.get("continuation") ?? "0");
2351
+ const maxContinuations = 5;
2352
+
2353
+ if (continuationCount >= maxContinuations) {
2354
+ writeJson(response, 200, {
2355
+ conversationId: continueConversationId,
2356
+ status: "max_continuations_reached",
2357
+ continuations: continuationCount,
2358
+ });
2359
+ return;
2360
+ }
2361
+
2362
+ const cronOwnerId = ownerId;
2363
+ const start = Date.now();
2364
+
2365
+ try {
2366
+ let conversation;
2367
+ let historyMessages: Message[] = [];
2368
+
2369
+ if (continueConversationId) {
2370
+ conversation = await conversationStore.get(continueConversationId);
2371
+ if (!conversation) {
2372
+ writeJson(response, 404, {
2373
+ code: "CONVERSATION_NOT_FOUND",
2374
+ message: "Continuation conversation not found",
2375
+ });
2376
+ return;
2377
+ }
2378
+ historyMessages = [...conversation.messages];
2379
+ } else {
2380
+ const timestamp = new Date().toISOString();
2381
+ conversation = await conversationStore.create(
2382
+ cronOwnerId,
2383
+ `[cron] ${jobName} ${timestamp}`,
2384
+ );
2385
+ }
2386
+
2387
+ const abortController = new AbortController();
2388
+ let assistantResponse = "";
2389
+ let latestRunId = "";
2390
+ const toolTimeline: string[] = [];
2391
+ const sections: Array<{ type: "text" | "tools"; content: string | string[] }> = [];
2392
+ let currentTools: string[] = [];
2393
+ let currentText = "";
2394
+ let runResult: { status: string; steps: number; continuation?: boolean } = {
2395
+ status: "completed",
2396
+ steps: 0,
2397
+ };
2398
+
2399
+ const platformMaxDurationSec = Number(process.env.PONCHO_MAX_DURATION) || 0;
2400
+ const softDeadlineMs = platformMaxDurationSec > 0
2401
+ ? platformMaxDurationSec * 800
2402
+ : 0;
2403
+
2404
+ for await (const event of harness.runWithTelemetry({
2405
+ task: cronJob.task,
2406
+ parameters: { __activeConversationId: conversation.conversationId },
2407
+ messages: historyMessages,
2408
+ abortSignal: abortController.signal,
2409
+ })) {
2410
+ if (event.type === "run:started") {
2411
+ latestRunId = event.runId;
2412
+ }
2413
+ if (event.type === "model:chunk") {
2414
+ if (currentTools.length > 0) {
2415
+ sections.push({ type: "tools", content: currentTools });
2416
+ currentTools = [];
2417
+ }
2418
+ assistantResponse += event.content;
2419
+ currentText += event.content;
2420
+ }
2421
+ if (event.type === "tool:started") {
2422
+ if (currentText.length > 0) {
2423
+ sections.push({ type: "text", content: currentText });
2424
+ currentText = "";
2425
+ }
2426
+ const toolText = `- start \`${event.tool}\``;
2427
+ toolTimeline.push(toolText);
2428
+ currentTools.push(toolText);
2429
+ }
2430
+ if (event.type === "tool:completed") {
2431
+ const toolText = `- done \`${event.tool}\` (${event.duration}ms)`;
2432
+ toolTimeline.push(toolText);
2433
+ currentTools.push(toolText);
2434
+ }
2435
+ if (event.type === "tool:error") {
2436
+ const toolText = `- error \`${event.tool}\`: ${event.error}`;
2437
+ toolTimeline.push(toolText);
2438
+ currentTools.push(toolText);
2439
+ }
2440
+ if (event.type === "run:completed") {
2441
+ runResult = {
2442
+ status: event.result.status,
2443
+ steps: event.result.steps,
2444
+ continuation: event.result.continuation,
2445
+ };
2446
+ if (!assistantResponse && event.result.response) {
2447
+ assistantResponse = event.result.response;
2448
+ }
2449
+ }
2450
+ await telemetry.emit(event);
2451
+ }
2452
+
2453
+ if (currentTools.length > 0) {
2454
+ sections.push({ type: "tools", content: currentTools });
2455
+ }
2456
+ if (currentText.length > 0) {
2457
+ sections.push({ type: "text", content: currentText });
2458
+ currentText = "";
2459
+ }
2460
+
2461
+ // Persist the conversation
2462
+ const hasContent = assistantResponse.length > 0 || toolTimeline.length > 0;
2463
+ const assistantMetadata =
2464
+ toolTimeline.length > 0 || sections.length > 0
2465
+ ? ({
2466
+ toolActivity: [...toolTimeline],
2467
+ sections: sections.length > 0 ? sections : undefined,
2468
+ } as Message["metadata"])
2469
+ : undefined;
2470
+ const messages: Message[] = [
2471
+ ...historyMessages,
2472
+ ...(continueConversationId
2473
+ ? []
2474
+ : [{ role: "user" as const, content: cronJob.task }]),
2475
+ ...(hasContent
2476
+ ? [{ role: "assistant" as const, content: assistantResponse, metadata: assistantMetadata }]
2477
+ : []),
2478
+ ];
2479
+ conversation.messages = messages;
2480
+ conversation.runtimeRunId = latestRunId || conversation.runtimeRunId;
2481
+ conversation.updatedAt = Date.now();
2482
+ await conversationStore.update(conversation);
2483
+
2484
+ // Self-continuation for serverless timeouts
2485
+ if (runResult.continuation && softDeadlineMs > 0) {
2486
+ const selfUrl = `http://${request.headers.host ?? "localhost"}${pathname}?continue=${encodeURIComponent(conversation.conversationId)}&continuation=${continuationCount + 1}`;
2487
+ try {
2488
+ const selfRes = await fetch(selfUrl, {
2489
+ method: "GET",
2490
+ headers: request.headers.authorization
2491
+ ? { authorization: request.headers.authorization }
2492
+ : {},
2493
+ });
2494
+ const selfBody = await selfRes.json() as Record<string, unknown>;
2495
+ writeJson(response, 200, {
2496
+ conversationId: conversation.conversationId,
2497
+ status: "continued",
2498
+ continuations: continuationCount + 1,
2499
+ finalResult: selfBody,
2500
+ duration: Date.now() - start,
2501
+ });
2502
+ } catch (continueError) {
2503
+ writeJson(response, 200, {
2504
+ conversationId: conversation.conversationId,
2505
+ status: "continuation_failed",
2506
+ error: continueError instanceof Error ? continueError.message : "Unknown error",
2507
+ duration: Date.now() - start,
2508
+ steps: runResult.steps,
2509
+ });
2510
+ }
2511
+ return;
2512
+ }
2513
+
2514
+ writeJson(response, 200, {
2515
+ conversationId: conversation.conversationId,
2516
+ status: runResult.status,
2517
+ response: assistantResponse.slice(0, 500),
2518
+ duration: Date.now() - start,
2519
+ steps: runResult.steps,
2520
+ });
2521
+ } catch (error) {
2522
+ writeJson(response, 500, {
2523
+ code: "CRON_RUN_ERROR",
2524
+ message: error instanceof Error ? error.message : "Unknown error",
2525
+ });
2526
+ }
2527
+ return;
2528
+ }
2529
+
2108
2530
  writeJson(response, 404, { error: "Not found" });
2109
2531
  };
2532
+ handler._harness = harness;
2533
+ handler._cronJobs = cronJobs;
2534
+ handler._conversationStore = conversationStore;
2535
+ return handler;
2110
2536
  };
2111
2537
 
2112
2538
  export const startDevServer = async (
2113
2539
  port: number,
2114
2540
  options?: { workingDir?: string },
2115
2541
  ): Promise<Server> => {
2542
+ const workingDir = options?.workingDir ?? process.cwd();
2116
2543
  const handler = await createRequestHandler(options);
2117
2544
  const server = createServer(handler);
2118
2545
  const actualPort = await listenOnAvailablePort(server, port);
@@ -2121,9 +2548,154 @@ export const startDevServer = async (
2121
2548
  }
2122
2549
  process.stdout.write(`Poncho dev server running at http://localhost:${actualPort}\n`);
2123
2550
 
2551
+ await checkVercelCronDrift(workingDir);
2552
+
2553
+ // ── Cron scheduler ─────────────────────────────────────────────
2554
+ const { Cron } = await import("croner");
2555
+ type CronJob = InstanceType<typeof Cron>;
2556
+ let activeJobs: CronJob[] = [];
2557
+
2558
+ const scheduleCronJobs = (jobs: Record<string, CronJobConfig>): void => {
2559
+ for (const job of activeJobs) {
2560
+ job.stop();
2561
+ }
2562
+ activeJobs = [];
2563
+
2564
+ const entries = Object.entries(jobs);
2565
+ if (entries.length === 0) return;
2566
+
2567
+ const harness = handler._harness;
2568
+ const store = handler._conversationStore;
2569
+ if (!harness || !store) return;
2570
+
2571
+ for (const [jobName, config] of entries) {
2572
+ const job = new Cron(
2573
+ config.schedule,
2574
+ { timezone: config.timezone ?? "UTC" },
2575
+ async () => {
2576
+ const timestamp = new Date().toISOString();
2577
+ process.stdout.write(`[cron] ${jobName} started at ${timestamp}\n`);
2578
+ const start = Date.now();
2579
+ try {
2580
+ const conversation = await store.create(
2581
+ "local-owner",
2582
+ `[cron] ${jobName} ${timestamp}`,
2583
+ );
2584
+ let assistantResponse = "";
2585
+ let steps = 0;
2586
+ const toolTimeline: string[] = [];
2587
+ const sections: Array<{ type: "text" | "tools"; content: string | string[] }> = [];
2588
+ let currentTools: string[] = [];
2589
+ let currentText = "";
2590
+ for await (const event of harness.runWithTelemetry({
2591
+ task: config.task,
2592
+ parameters: { __activeConversationId: conversation.conversationId },
2593
+ messages: [],
2594
+ })) {
2595
+ if (event.type === "model:chunk") {
2596
+ if (currentTools.length > 0) {
2597
+ sections.push({ type: "tools", content: currentTools });
2598
+ currentTools = [];
2599
+ }
2600
+ assistantResponse += event.content;
2601
+ currentText += event.content;
2602
+ }
2603
+ if (event.type === "tool:started") {
2604
+ if (currentText.length > 0) {
2605
+ sections.push({ type: "text", content: currentText });
2606
+ currentText = "";
2607
+ }
2608
+ const toolText = `- start \`${event.tool}\``;
2609
+ toolTimeline.push(toolText);
2610
+ currentTools.push(toolText);
2611
+ }
2612
+ if (event.type === "tool:completed") {
2613
+ const toolText = `- done \`${event.tool}\` (${event.duration}ms)`;
2614
+ toolTimeline.push(toolText);
2615
+ currentTools.push(toolText);
2616
+ }
2617
+ if (event.type === "tool:error") {
2618
+ const toolText = `- error \`${event.tool}\`: ${event.error}`;
2619
+ toolTimeline.push(toolText);
2620
+ currentTools.push(toolText);
2621
+ }
2622
+ if (event.type === "run:completed") {
2623
+ steps = event.result.steps;
2624
+ if (!assistantResponse && event.result.response) {
2625
+ assistantResponse = event.result.response;
2626
+ }
2627
+ }
2628
+ }
2629
+ if (currentTools.length > 0) {
2630
+ sections.push({ type: "tools", content: currentTools });
2631
+ }
2632
+ if (currentText.length > 0) {
2633
+ sections.push({ type: "text", content: currentText });
2634
+ }
2635
+ const hasContent = assistantResponse.length > 0 || toolTimeline.length > 0;
2636
+ const assistantMetadata =
2637
+ toolTimeline.length > 0 || sections.length > 0
2638
+ ? ({
2639
+ toolActivity: [...toolTimeline],
2640
+ sections: sections.length > 0 ? sections : undefined,
2641
+ } as Message["metadata"])
2642
+ : undefined;
2643
+ conversation.messages = [
2644
+ { role: "user", content: config.task },
2645
+ ...(hasContent
2646
+ ? [{ role: "assistant" as const, content: assistantResponse, metadata: assistantMetadata }]
2647
+ : []),
2648
+ ];
2649
+ conversation.updatedAt = Date.now();
2650
+ await store.update(conversation);
2651
+ const elapsed = ((Date.now() - start) / 1000).toFixed(1);
2652
+ process.stdout.write(
2653
+ `[cron] ${jobName} completed in ${elapsed}s (${steps} steps)\n`,
2654
+ );
2655
+ } catch (error) {
2656
+ const elapsed = ((Date.now() - start) / 1000).toFixed(1);
2657
+ const msg = error instanceof Error ? error.message : String(error);
2658
+ process.stderr.write(
2659
+ `[cron] ${jobName} failed after ${elapsed}s: ${msg}\n`,
2660
+ );
2661
+ }
2662
+ },
2663
+ );
2664
+ activeJobs.push(job);
2665
+ }
2666
+ process.stdout.write(
2667
+ `[cron] Scheduled ${entries.length} job${entries.length === 1 ? "" : "s"}: ${entries.map(([n]) => n).join(", ")}\n`,
2668
+ );
2669
+ };
2670
+
2671
+ const initialCronJobs = handler._cronJobs ?? {};
2672
+ scheduleCronJobs(initialCronJobs);
2673
+
2674
+ // Hot-reload cron config when AGENT.md changes
2675
+ const agentMdPath = resolve(workingDir, "AGENT.md");
2676
+ let reloadDebounce: ReturnType<typeof setTimeout> | null = null;
2677
+ const watcher = fsWatch(agentMdPath, () => {
2678
+ if (reloadDebounce) clearTimeout(reloadDebounce);
2679
+ reloadDebounce = setTimeout(async () => {
2680
+ try {
2681
+ const agentMd = await readFile(agentMdPath, "utf8");
2682
+ const parsed = parseAgentMarkdown(agentMd);
2683
+ const newJobs = parsed.frontmatter.cron ?? {};
2684
+ handler._cronJobs = newJobs;
2685
+ scheduleCronJobs(newJobs);
2686
+ process.stdout.write(`[cron] Reloaded: ${Object.keys(newJobs).length} jobs scheduled\n`);
2687
+ } catch {
2688
+ // Parse errors during editing are expected; ignore
2689
+ }
2690
+ }, 500);
2691
+ });
2692
+
2124
2693
  const shutdown = () => {
2694
+ watcher.close();
2695
+ for (const job of activeJobs) {
2696
+ job.stop();
2697
+ }
2125
2698
  server.close();
2126
- // Force-close any lingering connections so the port is freed immediately
2127
2699
  server.closeAllConnections?.();
2128
2700
  process.exit(0);
2129
2701
  };
@@ -2746,6 +3318,9 @@ export const buildTarget = async (
2746
3318
  options?: { force?: boolean },
2747
3319
  ): Promise<void> => {
2748
3320
  const normalizedTarget = normalizeDeployTarget(target);
3321
+ if (normalizedTarget === "vercel" && !options?.force) {
3322
+ await checkVercelCronDrift(workingDir);
3323
+ }
2749
3324
  const writtenPaths = await scaffoldDeployTarget(workingDir, normalizedTarget, {
2750
3325
  force: options?.force,
2751
3326
  });