@poncho-ai/cli 0.11.1 → 0.12.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.
@@ -1,5 +1,5 @@
1
1
 
2
- > @poncho-ai/cli@0.11.1 build /home/runner/work/poncho-ai/poncho-ai/packages/cli
2
+ > @poncho-ai/cli@0.12.0 build /home/runner/work/poncho-ai/poncho-ai/packages/cli
3
3
  > tsup src/index.ts src/cli.ts --format esm --dts
4
4
 
5
5
  CLI Building entry: src/cli.ts, src/index.ts
@@ -8,11 +8,11 @@
8
8
  CLI Target: es2022
9
9
  ESM Build start
10
10
  ESM dist/cli.js 94.00 B
11
- ESM dist/run-interactive-ink-7FP5PT7Q.js 53.83 KB
12
- ESM dist/chunk-T2F6ICXI.js 226.37 KB
11
+ ESM dist/run-interactive-ink-64QEOUXL.js 53.83 KB
13
12
  ESM dist/index.js 857.00 B
14
- ESM ⚡️ Build success in 54ms
13
+ ESM dist/chunk-XIFWXRUB.js 241.46 KB
14
+ ESM ⚡️ Build success in 59ms
15
15
  DTS Build start
16
- DTS ⚡️ Build success in 3582ms
16
+ DTS ⚡️ Build success in 3442ms
17
17
  DTS dist/cli.d.ts 20.00 B
18
- DTS dist/index.d.ts 3.36 KB
18
+ DTS dist/index.d.ts 3.56 KB
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # @poncho-ai/cli
2
2
 
3
+ ## 0.12.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#8](https://github.com/cesr/poncho-ai/pull/8) [`658bc54`](https://github.com/cesr/poncho-ai/commit/658bc54d391cb0b58aa678a2b86cd617eebdd8aa) Thanks [@cesr](https://github.com/cesr)! - Add cron job support for scheduled agent tasks. Define recurring jobs in AGENT.md frontmatter with schedule, task, and optional timezone. Includes in-process scheduler for local dev with hot-reload, HTTP endpoint for Vercel/serverless with self-continuation, Vercel scaffold generation with drift detection, and full tool activity tracking in cron conversations.
8
+
9
+ ### Patch Changes
10
+
11
+ - Updated dependencies [[`658bc54`](https://github.com/cesr/poncho-ai/commit/658bc54d391cb0b58aa678a2b86cd617eebdd8aa)]:
12
+ - @poncho-ai/harness@0.13.0
13
+
3
14
  ## 0.11.1
4
15
 
5
16
  ### Patch Changes
@@ -1,7 +1,7 @@
1
1
  // src/index.ts
2
2
  import { spawn } from "child_process";
3
3
  import { access as access2, cp, mkdir as mkdir3, readFile as readFile3, readdir, rm, stat, writeFile as writeFile3 } from "fs/promises";
4
- import { existsSync } from "fs";
4
+ import { existsSync, watch as fsWatch } from "fs";
5
5
  import {
6
6
  createServer
7
7
  } from "http";
@@ -18,6 +18,7 @@ import {
18
18
  ensureAgentIdentity as ensureAgentIdentity2,
19
19
  generateAgentId,
20
20
  loadPonchoConfig,
21
+ parseAgentMarkdown,
21
22
  resolveStateConfig
22
23
  } from "@poncho-ai/harness";
23
24
  import { getTextContent } from "@poncho-ai/sdk";
@@ -3736,6 +3737,7 @@ var consumeFirstRunIntro = async (workingDir, input2) => {
3736
3737
  "- **Enable auth**: Add bearer tokens or custom authentication",
3737
3738
  "- **Turn on telemetry**: Track usage with OpenTelemetry/OTLP",
3738
3739
  "- **Add MCP servers**: Connect external tool servers",
3740
+ "- **Schedule cron jobs**: Set up recurring tasks in AGENT.md frontmatter",
3739
3741
  "",
3740
3742
  "Just let me know what you'd like to work on!\n"
3741
3743
  ].join("\n");
@@ -4163,6 +4165,22 @@ ${name}/
4163
4165
  \u2514\u2500\u2500 fetch-page.ts
4164
4166
  \`\`\`
4165
4167
 
4168
+ ## Cron Jobs
4169
+
4170
+ Define scheduled tasks in \`AGENT.md\` frontmatter:
4171
+
4172
+ \`\`\`yaml
4173
+ cron:
4174
+ daily-report:
4175
+ schedule: "0 9 * * *"
4176
+ task: "Generate the daily sales report"
4177
+ \`\`\`
4178
+
4179
+ - \`poncho dev\`: jobs run via an in-process scheduler.
4180
+ - \`poncho build vercel\`: generates \`vercel.json\` cron entries.
4181
+ - Docker/Fly.io: scheduler runs automatically.
4182
+ - Trigger manually: \`curl http://localhost:3000/api/cron/daily-report\`
4183
+
4166
4184
  ## Deployment
4167
4185
 
4168
4186
  \`\`\`bash
@@ -4367,6 +4385,55 @@ var ensureRuntimeCliDependency = async (projectDir, cliVersion, config) => {
4367
4385
  `, "utf8");
4368
4386
  return { paths: [relative(projectDir, packageJsonPath)], addedDeps };
4369
4387
  };
4388
+ var checkVercelCronDrift = async (projectDir) => {
4389
+ const vercelJsonPath = resolve3(projectDir, "vercel.json");
4390
+ try {
4391
+ await access2(vercelJsonPath);
4392
+ } catch {
4393
+ return;
4394
+ }
4395
+ let agentCrons = {};
4396
+ try {
4397
+ const agentMd = await readFile3(resolve3(projectDir, "AGENT.md"), "utf8");
4398
+ const parsed = parseAgentMarkdown(agentMd);
4399
+ agentCrons = parsed.frontmatter.cron ?? {};
4400
+ } catch {
4401
+ return;
4402
+ }
4403
+ let vercelCrons = [];
4404
+ try {
4405
+ const raw = await readFile3(vercelJsonPath, "utf8");
4406
+ const vercelConfig = JSON.parse(raw);
4407
+ vercelCrons = vercelConfig.crons ?? [];
4408
+ } catch {
4409
+ return;
4410
+ }
4411
+ const vercelCronMap = new Map(
4412
+ vercelCrons.filter((c) => c.path.startsWith("/api/cron/")).map((c) => [decodeURIComponent(c.path.replace("/api/cron/", "")), c.schedule])
4413
+ );
4414
+ const diffs = [];
4415
+ for (const [jobName, job] of Object.entries(agentCrons)) {
4416
+ const existing = vercelCronMap.get(jobName);
4417
+ if (!existing) {
4418
+ diffs.push(` + missing job "${jobName}" (${job.schedule})`);
4419
+ } else if (existing !== job.schedule) {
4420
+ diffs.push(` ~ "${jobName}" schedule changed: "${existing}" \u2192 "${job.schedule}"`);
4421
+ }
4422
+ vercelCronMap.delete(jobName);
4423
+ }
4424
+ for (const [jobName, schedule] of vercelCronMap) {
4425
+ diffs.push(` - removed job "${jobName}" (${schedule})`);
4426
+ }
4427
+ if (diffs.length > 0) {
4428
+ process.stderr.write(
4429
+ `\u26A0 vercel.json crons are out of sync with AGENT.md:
4430
+ ${diffs.join("\n")}
4431
+ Run \`poncho build vercel --force\` to update.
4432
+
4433
+ `
4434
+ );
4435
+ }
4436
+ };
4370
4437
  var scaffoldDeployTarget = async (projectDir, target, options) => {
4371
4438
  const writtenPaths = [];
4372
4439
  const cliVersion = await readCliVersion();
@@ -4401,21 +4468,35 @@ export default async function handler(req, res) {
4401
4468
  { force: options?.force, writtenPaths, baseDir: projectDir }
4402
4469
  );
4403
4470
  const vercelConfigPath = resolve3(projectDir, "vercel.json");
4471
+ let vercelCrons;
4472
+ try {
4473
+ const agentMd = await readFile3(resolve3(projectDir, "AGENT.md"), "utf8");
4474
+ const parsed = parseAgentMarkdown(agentMd);
4475
+ if (parsed.frontmatter.cron) {
4476
+ vercelCrons = Object.entries(parsed.frontmatter.cron).map(
4477
+ ([jobName, job]) => ({
4478
+ path: `/api/cron/${encodeURIComponent(jobName)}`,
4479
+ schedule: job.schedule
4480
+ })
4481
+ );
4482
+ }
4483
+ } catch {
4484
+ }
4485
+ const vercelConfig = {
4486
+ version: 2,
4487
+ functions: {
4488
+ "api/index.mjs": {
4489
+ includeFiles: "{AGENT.md,poncho.config.js,skills/**,tests/**,node_modules/.pnpm/marked@*/node_modules/marked/lib/marked.umd.js}"
4490
+ }
4491
+ },
4492
+ routes: [{ src: "/(.*)", dest: "/api/index.mjs" }]
4493
+ };
4494
+ if (vercelCrons && vercelCrons.length > 0) {
4495
+ vercelConfig.crons = vercelCrons;
4496
+ }
4404
4497
  await writeScaffoldFile(
4405
4498
  vercelConfigPath,
4406
- `${JSON.stringify(
4407
- {
4408
- version: 2,
4409
- functions: {
4410
- "api/index.mjs": {
4411
- includeFiles: "{AGENT.md,poncho.config.js,skills/**,tests/**,node_modules/.pnpm/marked@*/node_modules/marked/lib/marked.umd.js}"
4412
- }
4413
- },
4414
- routes: [{ src: "/(.*)", dest: "/api/index.mjs" }]
4415
- },
4416
- null,
4417
- 2
4418
- )}
4499
+ `${JSON.stringify(vercelConfig, null, 2)}
4419
4500
  `,
4420
4501
  { force: options?.force, writtenPaths, baseDir: projectDir }
4421
4502
  );
@@ -4458,6 +4539,11 @@ export const handler = async (event = {}) => {
4458
4539
  });
4459
4540
  return { statusCode: 200, headers: { "content-type": "application/json" }, body };
4460
4541
  };
4542
+
4543
+ // Cron jobs: use AWS EventBridge (CloudWatch Events) to trigger scheduled invocations.
4544
+ // Create a rule for each cron job defined in AGENT.md that sends a GET request to:
4545
+ // /api/cron/<jobName>
4546
+ // Include the Authorization header with your PONCHO_AUTH_TOKEN as a Bearer token.
4461
4547
  `,
4462
4548
  { force: options?.force, writtenPaths, baseDir: projectDir }
4463
4549
  );
@@ -4687,6 +4773,7 @@ var createRequestHandler = async (options) => {
4687
4773
  let agentName = "Agent";
4688
4774
  let agentModelProvider = "anthropic";
4689
4775
  let agentModelName = "claude-opus-4-5";
4776
+ let cronJobs = {};
4690
4777
  try {
4691
4778
  const agentMd = await readFile3(resolve3(workingDir, "AGENT.md"), "utf8");
4692
4779
  const nameMatch = agentMd.match(/^name:\s*(.+)$/m);
@@ -4701,6 +4788,11 @@ var createRequestHandler = async (options) => {
4701
4788
  if (modelMatch?.[1]) {
4702
4789
  agentModelName = modelMatch[1].trim().replace(/^["']|["']$/g, "");
4703
4790
  }
4791
+ try {
4792
+ const parsed = parseAgentMarkdown(agentMd);
4793
+ cronJobs = parsed.frontmatter.cron ?? {};
4794
+ } catch {
4795
+ }
4704
4796
  } catch {
4705
4797
  }
4706
4798
  const runOwners = /* @__PURE__ */ new Map();
@@ -4811,7 +4903,7 @@ var createRequestHandler = async (options) => {
4811
4903
  }
4812
4904
  return verifyPassphrase(match[1], authToken);
4813
4905
  };
4814
- return async (request, response) => {
4906
+ const handler = async (request, response) => {
4815
4907
  if (!request.url || !request.method) {
4816
4908
  writeJson(response, 404, { error: "Not found" });
4817
4909
  return;
@@ -5533,10 +5625,183 @@ var createRequestHandler = async (options) => {
5533
5625
  }
5534
5626
  return;
5535
5627
  }
5628
+ const cronMatch = pathname.match(/^\/api\/cron\/([^/]+)$/);
5629
+ if (cronMatch && (request.method === "GET" || request.method === "POST")) {
5630
+ const jobName = decodeURIComponent(cronMatch[1] ?? "");
5631
+ const cronJob = cronJobs[jobName];
5632
+ if (!cronJob) {
5633
+ writeJson(response, 404, {
5634
+ code: "CRON_JOB_NOT_FOUND",
5635
+ message: `Cron job "${jobName}" is not defined in AGENT.md`
5636
+ });
5637
+ return;
5638
+ }
5639
+ const urlObj = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
5640
+ const continueConversationId = urlObj.searchParams.get("continue");
5641
+ const continuationCount = Number(urlObj.searchParams.get("continuation") ?? "0");
5642
+ const maxContinuations = 5;
5643
+ if (continuationCount >= maxContinuations) {
5644
+ writeJson(response, 200, {
5645
+ conversationId: continueConversationId,
5646
+ status: "max_continuations_reached",
5647
+ continuations: continuationCount
5648
+ });
5649
+ return;
5650
+ }
5651
+ const cronOwnerId = ownerId;
5652
+ const start = Date.now();
5653
+ try {
5654
+ let conversation;
5655
+ let historyMessages = [];
5656
+ if (continueConversationId) {
5657
+ conversation = await conversationStore.get(continueConversationId);
5658
+ if (!conversation) {
5659
+ writeJson(response, 404, {
5660
+ code: "CONVERSATION_NOT_FOUND",
5661
+ message: "Continuation conversation not found"
5662
+ });
5663
+ return;
5664
+ }
5665
+ historyMessages = [...conversation.messages];
5666
+ } else {
5667
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
5668
+ conversation = await conversationStore.create(
5669
+ cronOwnerId,
5670
+ `[cron] ${jobName} ${timestamp}`
5671
+ );
5672
+ }
5673
+ const abortController = new AbortController();
5674
+ let assistantResponse = "";
5675
+ let latestRunId = "";
5676
+ const toolTimeline = [];
5677
+ const sections = [];
5678
+ let currentTools = [];
5679
+ let currentText = "";
5680
+ let runResult = {
5681
+ status: "completed",
5682
+ steps: 0
5683
+ };
5684
+ const platformMaxDurationSec = Number(process.env.PONCHO_MAX_DURATION) || 0;
5685
+ const softDeadlineMs = platformMaxDurationSec > 0 ? platformMaxDurationSec * 800 : 0;
5686
+ for await (const event of harness.runWithTelemetry({
5687
+ task: cronJob.task,
5688
+ parameters: { __activeConversationId: conversation.conversationId },
5689
+ messages: historyMessages,
5690
+ abortSignal: abortController.signal
5691
+ })) {
5692
+ if (event.type === "run:started") {
5693
+ latestRunId = event.runId;
5694
+ }
5695
+ if (event.type === "model:chunk") {
5696
+ if (currentTools.length > 0) {
5697
+ sections.push({ type: "tools", content: currentTools });
5698
+ currentTools = [];
5699
+ }
5700
+ assistantResponse += event.content;
5701
+ currentText += event.content;
5702
+ }
5703
+ if (event.type === "tool:started") {
5704
+ if (currentText.length > 0) {
5705
+ sections.push({ type: "text", content: currentText });
5706
+ currentText = "";
5707
+ }
5708
+ const toolText = `- start \`${event.tool}\``;
5709
+ toolTimeline.push(toolText);
5710
+ currentTools.push(toolText);
5711
+ }
5712
+ if (event.type === "tool:completed") {
5713
+ const toolText = `- done \`${event.tool}\` (${event.duration}ms)`;
5714
+ toolTimeline.push(toolText);
5715
+ currentTools.push(toolText);
5716
+ }
5717
+ if (event.type === "tool:error") {
5718
+ const toolText = `- error \`${event.tool}\`: ${event.error}`;
5719
+ toolTimeline.push(toolText);
5720
+ currentTools.push(toolText);
5721
+ }
5722
+ if (event.type === "run:completed") {
5723
+ runResult = {
5724
+ status: event.result.status,
5725
+ steps: event.result.steps,
5726
+ continuation: event.result.continuation
5727
+ };
5728
+ if (!assistantResponse && event.result.response) {
5729
+ assistantResponse = event.result.response;
5730
+ }
5731
+ }
5732
+ await telemetry.emit(event);
5733
+ }
5734
+ if (currentTools.length > 0) {
5735
+ sections.push({ type: "tools", content: currentTools });
5736
+ }
5737
+ if (currentText.length > 0) {
5738
+ sections.push({ type: "text", content: currentText });
5739
+ currentText = "";
5740
+ }
5741
+ const hasContent = assistantResponse.length > 0 || toolTimeline.length > 0;
5742
+ const assistantMetadata = toolTimeline.length > 0 || sections.length > 0 ? {
5743
+ toolActivity: [...toolTimeline],
5744
+ sections: sections.length > 0 ? sections : void 0
5745
+ } : void 0;
5746
+ const messages = [
5747
+ ...historyMessages,
5748
+ ...continueConversationId ? [] : [{ role: "user", content: cronJob.task }],
5749
+ ...hasContent ? [{ role: "assistant", content: assistantResponse, metadata: assistantMetadata }] : []
5750
+ ];
5751
+ conversation.messages = messages;
5752
+ conversation.runtimeRunId = latestRunId || conversation.runtimeRunId;
5753
+ conversation.updatedAt = Date.now();
5754
+ await conversationStore.update(conversation);
5755
+ if (runResult.continuation && softDeadlineMs > 0) {
5756
+ const selfUrl = `http://${request.headers.host ?? "localhost"}${pathname}?continue=${encodeURIComponent(conversation.conversationId)}&continuation=${continuationCount + 1}`;
5757
+ try {
5758
+ const selfRes = await fetch(selfUrl, {
5759
+ method: "GET",
5760
+ headers: request.headers.authorization ? { authorization: request.headers.authorization } : {}
5761
+ });
5762
+ const selfBody = await selfRes.json();
5763
+ writeJson(response, 200, {
5764
+ conversationId: conversation.conversationId,
5765
+ status: "continued",
5766
+ continuations: continuationCount + 1,
5767
+ finalResult: selfBody,
5768
+ duration: Date.now() - start
5769
+ });
5770
+ } catch (continueError) {
5771
+ writeJson(response, 200, {
5772
+ conversationId: conversation.conversationId,
5773
+ status: "continuation_failed",
5774
+ error: continueError instanceof Error ? continueError.message : "Unknown error",
5775
+ duration: Date.now() - start,
5776
+ steps: runResult.steps
5777
+ });
5778
+ }
5779
+ return;
5780
+ }
5781
+ writeJson(response, 200, {
5782
+ conversationId: conversation.conversationId,
5783
+ status: runResult.status,
5784
+ response: assistantResponse.slice(0, 500),
5785
+ duration: Date.now() - start,
5786
+ steps: runResult.steps
5787
+ });
5788
+ } catch (error) {
5789
+ writeJson(response, 500, {
5790
+ code: "CRON_RUN_ERROR",
5791
+ message: error instanceof Error ? error.message : "Unknown error"
5792
+ });
5793
+ }
5794
+ return;
5795
+ }
5536
5796
  writeJson(response, 404, { error: "Not found" });
5537
5797
  };
5798
+ handler._harness = harness;
5799
+ handler._cronJobs = cronJobs;
5800
+ handler._conversationStore = conversationStore;
5801
+ return handler;
5538
5802
  };
5539
5803
  var startDevServer = async (port, options) => {
5804
+ const workingDir = options?.workingDir ?? process.cwd();
5540
5805
  const handler = await createRequestHandler(options);
5541
5806
  const server = createServer(handler);
5542
5807
  const actualPort = await listenOnAvailablePort(server, port);
@@ -5546,7 +5811,141 @@ var startDevServer = async (port, options) => {
5546
5811
  }
5547
5812
  process.stdout.write(`Poncho dev server running at http://localhost:${actualPort}
5548
5813
  `);
5814
+ await checkVercelCronDrift(workingDir);
5815
+ const { Cron } = await import("croner");
5816
+ let activeJobs = [];
5817
+ const scheduleCronJobs = (jobs) => {
5818
+ for (const job of activeJobs) {
5819
+ job.stop();
5820
+ }
5821
+ activeJobs = [];
5822
+ const entries = Object.entries(jobs);
5823
+ if (entries.length === 0) return;
5824
+ const harness = handler._harness;
5825
+ const store = handler._conversationStore;
5826
+ if (!harness || !store) return;
5827
+ for (const [jobName, config] of entries) {
5828
+ const job = new Cron(
5829
+ config.schedule,
5830
+ { timezone: config.timezone ?? "UTC" },
5831
+ async () => {
5832
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
5833
+ process.stdout.write(`[cron] ${jobName} started at ${timestamp}
5834
+ `);
5835
+ const start = Date.now();
5836
+ try {
5837
+ const conversation = await store.create(
5838
+ "local-owner",
5839
+ `[cron] ${jobName} ${timestamp}`
5840
+ );
5841
+ let assistantResponse = "";
5842
+ let steps = 0;
5843
+ const toolTimeline = [];
5844
+ const sections = [];
5845
+ let currentTools = [];
5846
+ let currentText = "";
5847
+ for await (const event of harness.runWithTelemetry({
5848
+ task: config.task,
5849
+ parameters: { __activeConversationId: conversation.conversationId },
5850
+ messages: []
5851
+ })) {
5852
+ if (event.type === "model:chunk") {
5853
+ if (currentTools.length > 0) {
5854
+ sections.push({ type: "tools", content: currentTools });
5855
+ currentTools = [];
5856
+ }
5857
+ assistantResponse += event.content;
5858
+ currentText += event.content;
5859
+ }
5860
+ if (event.type === "tool:started") {
5861
+ if (currentText.length > 0) {
5862
+ sections.push({ type: "text", content: currentText });
5863
+ currentText = "";
5864
+ }
5865
+ const toolText = `- start \`${event.tool}\``;
5866
+ toolTimeline.push(toolText);
5867
+ currentTools.push(toolText);
5868
+ }
5869
+ if (event.type === "tool:completed") {
5870
+ const toolText = `- done \`${event.tool}\` (${event.duration}ms)`;
5871
+ toolTimeline.push(toolText);
5872
+ currentTools.push(toolText);
5873
+ }
5874
+ if (event.type === "tool:error") {
5875
+ const toolText = `- error \`${event.tool}\`: ${event.error}`;
5876
+ toolTimeline.push(toolText);
5877
+ currentTools.push(toolText);
5878
+ }
5879
+ if (event.type === "run:completed") {
5880
+ steps = event.result.steps;
5881
+ if (!assistantResponse && event.result.response) {
5882
+ assistantResponse = event.result.response;
5883
+ }
5884
+ }
5885
+ }
5886
+ if (currentTools.length > 0) {
5887
+ sections.push({ type: "tools", content: currentTools });
5888
+ }
5889
+ if (currentText.length > 0) {
5890
+ sections.push({ type: "text", content: currentText });
5891
+ }
5892
+ const hasContent = assistantResponse.length > 0 || toolTimeline.length > 0;
5893
+ const assistantMetadata = toolTimeline.length > 0 || sections.length > 0 ? {
5894
+ toolActivity: [...toolTimeline],
5895
+ sections: sections.length > 0 ? sections : void 0
5896
+ } : void 0;
5897
+ conversation.messages = [
5898
+ { role: "user", content: config.task },
5899
+ ...hasContent ? [{ role: "assistant", content: assistantResponse, metadata: assistantMetadata }] : []
5900
+ ];
5901
+ conversation.updatedAt = Date.now();
5902
+ await store.update(conversation);
5903
+ const elapsed = ((Date.now() - start) / 1e3).toFixed(1);
5904
+ process.stdout.write(
5905
+ `[cron] ${jobName} completed in ${elapsed}s (${steps} steps)
5906
+ `
5907
+ );
5908
+ } catch (error) {
5909
+ const elapsed = ((Date.now() - start) / 1e3).toFixed(1);
5910
+ const msg = error instanceof Error ? error.message : String(error);
5911
+ process.stderr.write(
5912
+ `[cron] ${jobName} failed after ${elapsed}s: ${msg}
5913
+ `
5914
+ );
5915
+ }
5916
+ }
5917
+ );
5918
+ activeJobs.push(job);
5919
+ }
5920
+ process.stdout.write(
5921
+ `[cron] Scheduled ${entries.length} job${entries.length === 1 ? "" : "s"}: ${entries.map(([n]) => n).join(", ")}
5922
+ `
5923
+ );
5924
+ };
5925
+ const initialCronJobs = handler._cronJobs ?? {};
5926
+ scheduleCronJobs(initialCronJobs);
5927
+ const agentMdPath = resolve3(workingDir, "AGENT.md");
5928
+ let reloadDebounce = null;
5929
+ const watcher = fsWatch(agentMdPath, () => {
5930
+ if (reloadDebounce) clearTimeout(reloadDebounce);
5931
+ reloadDebounce = setTimeout(async () => {
5932
+ try {
5933
+ const agentMd = await readFile3(agentMdPath, "utf8");
5934
+ const parsed = parseAgentMarkdown(agentMd);
5935
+ const newJobs = parsed.frontmatter.cron ?? {};
5936
+ handler._cronJobs = newJobs;
5937
+ scheduleCronJobs(newJobs);
5938
+ process.stdout.write(`[cron] Reloaded: ${Object.keys(newJobs).length} jobs scheduled
5939
+ `);
5940
+ } catch {
5941
+ }
5942
+ }, 500);
5943
+ });
5549
5944
  const shutdown = () => {
5945
+ watcher.close();
5946
+ for (const job of activeJobs) {
5947
+ job.stop();
5948
+ }
5550
5949
  server.close();
5551
5950
  server.closeAllConnections?.();
5552
5951
  process.exit(0);
@@ -5637,7 +6036,7 @@ var runInteractive = async (workingDir, params) => {
5637
6036
  await harness.initialize();
5638
6037
  const identity = await ensureAgentIdentity2(workingDir);
5639
6038
  try {
5640
- const { runInteractiveInk } = await import("./run-interactive-ink-7FP5PT7Q.js");
6039
+ const { runInteractiveInk } = await import("./run-interactive-ink-64QEOUXL.js");
5641
6040
  await runInteractiveInk({
5642
6041
  harness,
5643
6042
  params,
@@ -5998,6 +6397,9 @@ Test summary: ${passed} passed, ${failed} failed
5998
6397
  };
5999
6398
  var buildTarget = async (workingDir, target, options) => {
6000
6399
  const normalizedTarget = normalizeDeployTarget2(target);
6400
+ if (normalizedTarget === "vercel" && !options?.force) {
6401
+ await checkVercelCronDrift(workingDir);
6402
+ }
6001
6403
  const writtenPaths = await scaffoldDeployTarget(workingDir, normalizedTarget, {
6002
6404
  force: options?.force
6003
6405
  });
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  main
4
- } from "./chunk-T2F6ICXI.js";
4
+ } from "./chunk-XIFWXRUB.js";
5
5
 
6
6
  // src/cli.ts
7
7
  void main();
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { IncomingMessage, ServerResponse, Server } from 'node:http';
2
+ import { AgentHarness, CronJobConfig, ConversationStore } from '@poncho-ai/harness';
2
3
  import { Command } from 'commander';
3
4
 
4
5
  type InitOnboardingOptions = {
@@ -17,7 +18,11 @@ declare const initProject: (projectName: string, options?: {
17
18
  envExampleOverride?: string;
18
19
  }) => Promise<void>;
19
20
  declare const updateAgentGuidance: (workingDir: string) => Promise<boolean>;
20
- type RequestHandler = (request: IncomingMessage, response: ServerResponse) => Promise<void>;
21
+ type RequestHandler = ((request: IncomingMessage, response: ServerResponse) => Promise<void>) & {
22
+ _harness?: AgentHarness;
23
+ _cronJobs?: Record<string, CronJobConfig>;
24
+ _conversationStore?: ConversationStore;
25
+ };
21
26
  declare const createRequestHandler: (options?: {
22
27
  workingDir?: string;
23
28
  }) => Promise<RequestHandler>;
package/dist/index.js CHANGED
@@ -23,7 +23,7 @@ import {
23
23
  runTests,
24
24
  startDevServer,
25
25
  updateAgentGuidance
26
- } from "./chunk-T2F6ICXI.js";
26
+ } from "./chunk-XIFWXRUB.js";
27
27
  export {
28
28
  addSkill,
29
29
  buildCli,
@@ -2,7 +2,7 @@ import {
2
2
  consumeFirstRunIntro,
3
3
  inferConversationTitle,
4
4
  resolveHarnessEnvironment
5
- } from "./chunk-T2F6ICXI.js";
5
+ } from "./chunk-XIFWXRUB.js";
6
6
 
7
7
  // src/run-interactive-ink.ts
8
8
  import * as readline from "readline";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poncho-ai/cli",
3
- "version": "0.11.1",
3
+ "version": "0.12.0",
4
4
  "description": "CLI for building and deploying AI agents",
5
5
  "repository": {
6
6
  "type": "git",
@@ -20,13 +20,14 @@
20
20
  "@inquirer/prompts": "^8.2.0",
21
21
  "busboy": "^1.6.0",
22
22
  "commander": "^12.0.0",
23
+ "croner": "^10.0.1",
23
24
  "dotenv": "^16.4.0",
24
25
  "ink": "^6.7.0",
25
26
  "marked": "^17.0.2",
26
27
  "react": "^19.2.4",
27
28
  "react-devtools-core": "^6.1.5",
28
29
  "yaml": "^2.8.1",
29
- "@poncho-ai/harness": "0.12.0",
30
+ "@poncho-ai/harness": "0.13.0",
30
31
  "@poncho-ai/sdk": "1.0.0"
31
32
  },
32
33
  "devDependencies": {
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,7 +20,9 @@ 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,
@@ -549,6 +551,22 @@ ${name}/
549
551
  └── fetch-page.ts
550
552
  \`\`\`
551
553
 
554
+ ## Cron Jobs
555
+
556
+ Define scheduled tasks in \`AGENT.md\` frontmatter:
557
+
558
+ \`\`\`yaml
559
+ cron:
560
+ daily-report:
561
+ schedule: "0 9 * * *"
562
+ task: "Generate the daily sales report"
563
+ \`\`\`
564
+
565
+ - \`poncho dev\`: jobs run via an in-process scheduler.
566
+ - \`poncho build vercel\`: generates \`vercel.json\` cron entries.
567
+ - Docker/Fly.io: scheduler runs automatically.
568
+ - Trigger manually: \`curl http://localhost:3000/api/cron/daily-report\`
569
+
552
570
  ## Deployment
553
571
 
554
572
  \`\`\`bash
@@ -796,6 +814,54 @@ const ensureRuntimeCliDependency = async (
796
814
  return { paths: [relative(projectDir, packageJsonPath)], addedDeps };
797
815
  };
798
816
 
817
+ const checkVercelCronDrift = async (projectDir: string): Promise<void> => {
818
+ const vercelJsonPath = resolve(projectDir, "vercel.json");
819
+ try {
820
+ await access(vercelJsonPath);
821
+ } catch {
822
+ return;
823
+ }
824
+ let agentCrons: Record<string, CronJobConfig> = {};
825
+ try {
826
+ const agentMd = await readFile(resolve(projectDir, "AGENT.md"), "utf8");
827
+ const parsed = parseAgentMarkdown(agentMd);
828
+ agentCrons = parsed.frontmatter.cron ?? {};
829
+ } catch {
830
+ return;
831
+ }
832
+ let vercelCrons: Array<{ path: string; schedule: string }> = [];
833
+ try {
834
+ const raw = await readFile(vercelJsonPath, "utf8");
835
+ const vercelConfig = JSON.parse(raw) as { crons?: Array<{ path: string; schedule: string }> };
836
+ vercelCrons = vercelConfig.crons ?? [];
837
+ } catch {
838
+ return;
839
+ }
840
+ const vercelCronMap = new Map(
841
+ vercelCrons
842
+ .filter((c) => c.path.startsWith("/api/cron/"))
843
+ .map((c) => [decodeURIComponent(c.path.replace("/api/cron/", "")), c.schedule]),
844
+ );
845
+ const diffs: string[] = [];
846
+ for (const [jobName, job] of Object.entries(agentCrons)) {
847
+ const existing = vercelCronMap.get(jobName);
848
+ if (!existing) {
849
+ diffs.push(` + missing job "${jobName}" (${job.schedule})`);
850
+ } else if (existing !== job.schedule) {
851
+ diffs.push(` ~ "${jobName}" schedule changed: "${existing}" → "${job.schedule}"`);
852
+ }
853
+ vercelCronMap.delete(jobName);
854
+ }
855
+ for (const [jobName, schedule] of vercelCronMap) {
856
+ diffs.push(` - removed job "${jobName}" (${schedule})`);
857
+ }
858
+ if (diffs.length > 0) {
859
+ process.stderr.write(
860
+ `\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`,
861
+ );
862
+ }
863
+ };
864
+
799
865
  const scaffoldDeployTarget = async (
800
866
  projectDir: string,
801
867
  target: DeployScaffoldTarget,
@@ -835,22 +901,37 @@ export default async function handler(req, res) {
835
901
  { force: options?.force, writtenPaths, baseDir: projectDir },
836
902
  );
837
903
  const vercelConfigPath = resolve(projectDir, "vercel.json");
904
+ let vercelCrons: Array<{ path: string; schedule: string }> | undefined;
905
+ try {
906
+ const agentMd = await readFile(resolve(projectDir, "AGENT.md"), "utf8");
907
+ const parsed = parseAgentMarkdown(agentMd);
908
+ if (parsed.frontmatter.cron) {
909
+ vercelCrons = Object.entries(parsed.frontmatter.cron).map(
910
+ ([jobName, job]) => ({
911
+ path: `/api/cron/${encodeURIComponent(jobName)}`,
912
+ schedule: job.schedule,
913
+ }),
914
+ );
915
+ }
916
+ } catch {
917
+ // AGENT.md may not exist yet during init; skip cron generation
918
+ }
919
+ const vercelConfig: Record<string, unknown> = {
920
+ version: 2,
921
+ functions: {
922
+ "api/index.mjs": {
923
+ includeFiles:
924
+ "{AGENT.md,poncho.config.js,skills/**,tests/**,node_modules/.pnpm/marked@*/node_modules/marked/lib/marked.umd.js}",
925
+ },
926
+ },
927
+ routes: [{ src: "/(.*)", dest: "/api/index.mjs" }],
928
+ };
929
+ if (vercelCrons && vercelCrons.length > 0) {
930
+ vercelConfig.crons = vercelCrons;
931
+ }
838
932
  await writeScaffoldFile(
839
933
  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`,
934
+ `${JSON.stringify(vercelConfig, null, 2)}\n`,
854
935
  { force: options?.force, writtenPaths, baseDir: projectDir },
855
936
  );
856
937
  } else if (target === "docker") {
@@ -892,6 +973,11 @@ export const handler = async (event = {}) => {
892
973
  });
893
974
  return { statusCode: 200, headers: { "content-type": "application/json" }, body };
894
975
  };
976
+
977
+ // Cron jobs: use AWS EventBridge (CloudWatch Events) to trigger scheduled invocations.
978
+ // Create a rule for each cron job defined in AGENT.md that sends a GET request to:
979
+ // /api/cron/<jobName>
980
+ // Include the Authorization header with your PONCHO_AUTH_TOKEN as a Bearer token.
895
981
  `,
896
982
  { force: options?.force, writtenPaths, baseDir: projectDir },
897
983
  );
@@ -1131,10 +1217,14 @@ export const updateAgentGuidance = async (workingDir: string): Promise<boolean>
1131
1217
  const formatSseEvent = (event: AgentEvent): string =>
1132
1218
  `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`;
1133
1219
 
1134
- export type RequestHandler = (
1220
+ export type RequestHandler = ((
1135
1221
  request: IncomingMessage,
1136
1222
  response: ServerResponse,
1137
- ) => Promise<void>;
1223
+ ) => Promise<void>) & {
1224
+ _harness?: AgentHarness;
1225
+ _cronJobs?: Record<string, CronJobConfig>;
1226
+ _conversationStore?: ConversationStore;
1227
+ };
1138
1228
 
1139
1229
  export const createRequestHandler = async (options?: {
1140
1230
  workingDir?: string;
@@ -1145,6 +1235,7 @@ export const createRequestHandler = async (options?: {
1145
1235
  let agentName = "Agent";
1146
1236
  let agentModelProvider = "anthropic";
1147
1237
  let agentModelName = "claude-opus-4-5";
1238
+ let cronJobs: Record<string, CronJobConfig> = {};
1148
1239
  try {
1149
1240
  const agentMd = await readFile(resolve(workingDir, "AGENT.md"), "utf8");
1150
1241
  const nameMatch = agentMd.match(/^name:\s*(.+)$/m);
@@ -1159,6 +1250,12 @@ export const createRequestHandler = async (options?: {
1159
1250
  if (modelMatch?.[1]) {
1160
1251
  agentModelName = modelMatch[1].trim().replace(/^["']|["']$/g, "");
1161
1252
  }
1253
+ try {
1254
+ const parsed = parseAgentMarkdown(agentMd);
1255
+ cronJobs = parsed.frontmatter.cron ?? {};
1256
+ } catch {
1257
+ // Cron parsing failure should not block the server
1258
+ }
1162
1259
  } catch {}
1163
1260
  const runOwners = new Map<string, string>();
1164
1261
  const runConversations = new Map<string, string>();
@@ -1300,7 +1397,7 @@ export const createRequestHandler = async (options?: {
1300
1397
  return verifyPassphrase(match[1], authToken);
1301
1398
  };
1302
1399
 
1303
- return async (request: IncomingMessage, response: ServerResponse) => {
1400
+ const handler: RequestHandler = async (request: IncomingMessage, response: ServerResponse) => {
1304
1401
  if (!request.url || !request.method) {
1305
1402
  writeJson(response, 404, { error: "Not found" });
1306
1403
  return;
@@ -2105,14 +2202,214 @@ export const createRequestHandler = async (options?: {
2105
2202
  return;
2106
2203
  }
2107
2204
 
2205
+ // ── Cron job endpoint ──────────────────────────────────────────
2206
+ const cronMatch = pathname.match(/^\/api\/cron\/([^/]+)$/);
2207
+ if (cronMatch && (request.method === "GET" || request.method === "POST")) {
2208
+ const jobName = decodeURIComponent(cronMatch[1] ?? "");
2209
+ const cronJob = cronJobs[jobName];
2210
+ if (!cronJob) {
2211
+ writeJson(response, 404, {
2212
+ code: "CRON_JOB_NOT_FOUND",
2213
+ message: `Cron job "${jobName}" is not defined in AGENT.md`,
2214
+ });
2215
+ return;
2216
+ }
2217
+
2218
+ const urlObj = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
2219
+ const continueConversationId = urlObj.searchParams.get("continue");
2220
+ const continuationCount = Number(urlObj.searchParams.get("continuation") ?? "0");
2221
+ const maxContinuations = 5;
2222
+
2223
+ if (continuationCount >= maxContinuations) {
2224
+ writeJson(response, 200, {
2225
+ conversationId: continueConversationId,
2226
+ status: "max_continuations_reached",
2227
+ continuations: continuationCount,
2228
+ });
2229
+ return;
2230
+ }
2231
+
2232
+ const cronOwnerId = ownerId;
2233
+ const start = Date.now();
2234
+
2235
+ try {
2236
+ let conversation;
2237
+ let historyMessages: Message[] = [];
2238
+
2239
+ if (continueConversationId) {
2240
+ conversation = await conversationStore.get(continueConversationId);
2241
+ if (!conversation) {
2242
+ writeJson(response, 404, {
2243
+ code: "CONVERSATION_NOT_FOUND",
2244
+ message: "Continuation conversation not found",
2245
+ });
2246
+ return;
2247
+ }
2248
+ historyMessages = [...conversation.messages];
2249
+ } else {
2250
+ const timestamp = new Date().toISOString();
2251
+ conversation = await conversationStore.create(
2252
+ cronOwnerId,
2253
+ `[cron] ${jobName} ${timestamp}`,
2254
+ );
2255
+ }
2256
+
2257
+ const abortController = new AbortController();
2258
+ let assistantResponse = "";
2259
+ let latestRunId = "";
2260
+ const toolTimeline: string[] = [];
2261
+ const sections: Array<{ type: "text" | "tools"; content: string | string[] }> = [];
2262
+ let currentTools: string[] = [];
2263
+ let currentText = "";
2264
+ let runResult: { status: string; steps: number; continuation?: boolean } = {
2265
+ status: "completed",
2266
+ steps: 0,
2267
+ };
2268
+
2269
+ const platformMaxDurationSec = Number(process.env.PONCHO_MAX_DURATION) || 0;
2270
+ const softDeadlineMs = platformMaxDurationSec > 0
2271
+ ? platformMaxDurationSec * 800
2272
+ : 0;
2273
+
2274
+ for await (const event of harness.runWithTelemetry({
2275
+ task: cronJob.task,
2276
+ parameters: { __activeConversationId: conversation.conversationId },
2277
+ messages: historyMessages,
2278
+ abortSignal: abortController.signal,
2279
+ })) {
2280
+ if (event.type === "run:started") {
2281
+ latestRunId = event.runId;
2282
+ }
2283
+ if (event.type === "model:chunk") {
2284
+ if (currentTools.length > 0) {
2285
+ sections.push({ type: "tools", content: currentTools });
2286
+ currentTools = [];
2287
+ }
2288
+ assistantResponse += event.content;
2289
+ currentText += event.content;
2290
+ }
2291
+ if (event.type === "tool:started") {
2292
+ if (currentText.length > 0) {
2293
+ sections.push({ type: "text", content: currentText });
2294
+ currentText = "";
2295
+ }
2296
+ const toolText = `- start \`${event.tool}\``;
2297
+ toolTimeline.push(toolText);
2298
+ currentTools.push(toolText);
2299
+ }
2300
+ if (event.type === "tool:completed") {
2301
+ const toolText = `- done \`${event.tool}\` (${event.duration}ms)`;
2302
+ toolTimeline.push(toolText);
2303
+ currentTools.push(toolText);
2304
+ }
2305
+ if (event.type === "tool:error") {
2306
+ const toolText = `- error \`${event.tool}\`: ${event.error}`;
2307
+ toolTimeline.push(toolText);
2308
+ currentTools.push(toolText);
2309
+ }
2310
+ if (event.type === "run:completed") {
2311
+ runResult = {
2312
+ status: event.result.status,
2313
+ steps: event.result.steps,
2314
+ continuation: event.result.continuation,
2315
+ };
2316
+ if (!assistantResponse && event.result.response) {
2317
+ assistantResponse = event.result.response;
2318
+ }
2319
+ }
2320
+ await telemetry.emit(event);
2321
+ }
2322
+
2323
+ if (currentTools.length > 0) {
2324
+ sections.push({ type: "tools", content: currentTools });
2325
+ }
2326
+ if (currentText.length > 0) {
2327
+ sections.push({ type: "text", content: currentText });
2328
+ currentText = "";
2329
+ }
2330
+
2331
+ // Persist the conversation
2332
+ const hasContent = assistantResponse.length > 0 || toolTimeline.length > 0;
2333
+ const assistantMetadata =
2334
+ toolTimeline.length > 0 || sections.length > 0
2335
+ ? ({
2336
+ toolActivity: [...toolTimeline],
2337
+ sections: sections.length > 0 ? sections : undefined,
2338
+ } as Message["metadata"])
2339
+ : undefined;
2340
+ const messages: Message[] = [
2341
+ ...historyMessages,
2342
+ ...(continueConversationId
2343
+ ? []
2344
+ : [{ role: "user" as const, content: cronJob.task }]),
2345
+ ...(hasContent
2346
+ ? [{ role: "assistant" as const, content: assistantResponse, metadata: assistantMetadata }]
2347
+ : []),
2348
+ ];
2349
+ conversation.messages = messages;
2350
+ conversation.runtimeRunId = latestRunId || conversation.runtimeRunId;
2351
+ conversation.updatedAt = Date.now();
2352
+ await conversationStore.update(conversation);
2353
+
2354
+ // Self-continuation for serverless timeouts
2355
+ if (runResult.continuation && softDeadlineMs > 0) {
2356
+ const selfUrl = `http://${request.headers.host ?? "localhost"}${pathname}?continue=${encodeURIComponent(conversation.conversationId)}&continuation=${continuationCount + 1}`;
2357
+ try {
2358
+ const selfRes = await fetch(selfUrl, {
2359
+ method: "GET",
2360
+ headers: request.headers.authorization
2361
+ ? { authorization: request.headers.authorization }
2362
+ : {},
2363
+ });
2364
+ const selfBody = await selfRes.json() as Record<string, unknown>;
2365
+ writeJson(response, 200, {
2366
+ conversationId: conversation.conversationId,
2367
+ status: "continued",
2368
+ continuations: continuationCount + 1,
2369
+ finalResult: selfBody,
2370
+ duration: Date.now() - start,
2371
+ });
2372
+ } catch (continueError) {
2373
+ writeJson(response, 200, {
2374
+ conversationId: conversation.conversationId,
2375
+ status: "continuation_failed",
2376
+ error: continueError instanceof Error ? continueError.message : "Unknown error",
2377
+ duration: Date.now() - start,
2378
+ steps: runResult.steps,
2379
+ });
2380
+ }
2381
+ return;
2382
+ }
2383
+
2384
+ writeJson(response, 200, {
2385
+ conversationId: conversation.conversationId,
2386
+ status: runResult.status,
2387
+ response: assistantResponse.slice(0, 500),
2388
+ duration: Date.now() - start,
2389
+ steps: runResult.steps,
2390
+ });
2391
+ } catch (error) {
2392
+ writeJson(response, 500, {
2393
+ code: "CRON_RUN_ERROR",
2394
+ message: error instanceof Error ? error.message : "Unknown error",
2395
+ });
2396
+ }
2397
+ return;
2398
+ }
2399
+
2108
2400
  writeJson(response, 404, { error: "Not found" });
2109
2401
  };
2402
+ handler._harness = harness;
2403
+ handler._cronJobs = cronJobs;
2404
+ handler._conversationStore = conversationStore;
2405
+ return handler;
2110
2406
  };
2111
2407
 
2112
2408
  export const startDevServer = async (
2113
2409
  port: number,
2114
2410
  options?: { workingDir?: string },
2115
2411
  ): Promise<Server> => {
2412
+ const workingDir = options?.workingDir ?? process.cwd();
2116
2413
  const handler = await createRequestHandler(options);
2117
2414
  const server = createServer(handler);
2118
2415
  const actualPort = await listenOnAvailablePort(server, port);
@@ -2121,9 +2418,154 @@ export const startDevServer = async (
2121
2418
  }
2122
2419
  process.stdout.write(`Poncho dev server running at http://localhost:${actualPort}\n`);
2123
2420
 
2421
+ await checkVercelCronDrift(workingDir);
2422
+
2423
+ // ── Cron scheduler ─────────────────────────────────────────────
2424
+ const { Cron } = await import("croner");
2425
+ type CronJob = InstanceType<typeof Cron>;
2426
+ let activeJobs: CronJob[] = [];
2427
+
2428
+ const scheduleCronJobs = (jobs: Record<string, CronJobConfig>): void => {
2429
+ for (const job of activeJobs) {
2430
+ job.stop();
2431
+ }
2432
+ activeJobs = [];
2433
+
2434
+ const entries = Object.entries(jobs);
2435
+ if (entries.length === 0) return;
2436
+
2437
+ const harness = handler._harness;
2438
+ const store = handler._conversationStore;
2439
+ if (!harness || !store) return;
2440
+
2441
+ for (const [jobName, config] of entries) {
2442
+ const job = new Cron(
2443
+ config.schedule,
2444
+ { timezone: config.timezone ?? "UTC" },
2445
+ async () => {
2446
+ const timestamp = new Date().toISOString();
2447
+ process.stdout.write(`[cron] ${jobName} started at ${timestamp}\n`);
2448
+ const start = Date.now();
2449
+ try {
2450
+ const conversation = await store.create(
2451
+ "local-owner",
2452
+ `[cron] ${jobName} ${timestamp}`,
2453
+ );
2454
+ let assistantResponse = "";
2455
+ let steps = 0;
2456
+ const toolTimeline: string[] = [];
2457
+ const sections: Array<{ type: "text" | "tools"; content: string | string[] }> = [];
2458
+ let currentTools: string[] = [];
2459
+ let currentText = "";
2460
+ for await (const event of harness.runWithTelemetry({
2461
+ task: config.task,
2462
+ parameters: { __activeConversationId: conversation.conversationId },
2463
+ messages: [],
2464
+ })) {
2465
+ if (event.type === "model:chunk") {
2466
+ if (currentTools.length > 0) {
2467
+ sections.push({ type: "tools", content: currentTools });
2468
+ currentTools = [];
2469
+ }
2470
+ assistantResponse += event.content;
2471
+ currentText += event.content;
2472
+ }
2473
+ if (event.type === "tool:started") {
2474
+ if (currentText.length > 0) {
2475
+ sections.push({ type: "text", content: currentText });
2476
+ currentText = "";
2477
+ }
2478
+ const toolText = `- start \`${event.tool}\``;
2479
+ toolTimeline.push(toolText);
2480
+ currentTools.push(toolText);
2481
+ }
2482
+ if (event.type === "tool:completed") {
2483
+ const toolText = `- done \`${event.tool}\` (${event.duration}ms)`;
2484
+ toolTimeline.push(toolText);
2485
+ currentTools.push(toolText);
2486
+ }
2487
+ if (event.type === "tool:error") {
2488
+ const toolText = `- error \`${event.tool}\`: ${event.error}`;
2489
+ toolTimeline.push(toolText);
2490
+ currentTools.push(toolText);
2491
+ }
2492
+ if (event.type === "run:completed") {
2493
+ steps = event.result.steps;
2494
+ if (!assistantResponse && event.result.response) {
2495
+ assistantResponse = event.result.response;
2496
+ }
2497
+ }
2498
+ }
2499
+ if (currentTools.length > 0) {
2500
+ sections.push({ type: "tools", content: currentTools });
2501
+ }
2502
+ if (currentText.length > 0) {
2503
+ sections.push({ type: "text", content: currentText });
2504
+ }
2505
+ const hasContent = assistantResponse.length > 0 || toolTimeline.length > 0;
2506
+ const assistantMetadata =
2507
+ toolTimeline.length > 0 || sections.length > 0
2508
+ ? ({
2509
+ toolActivity: [...toolTimeline],
2510
+ sections: sections.length > 0 ? sections : undefined,
2511
+ } as Message["metadata"])
2512
+ : undefined;
2513
+ conversation.messages = [
2514
+ { role: "user", content: config.task },
2515
+ ...(hasContent
2516
+ ? [{ role: "assistant" as const, content: assistantResponse, metadata: assistantMetadata }]
2517
+ : []),
2518
+ ];
2519
+ conversation.updatedAt = Date.now();
2520
+ await store.update(conversation);
2521
+ const elapsed = ((Date.now() - start) / 1000).toFixed(1);
2522
+ process.stdout.write(
2523
+ `[cron] ${jobName} completed in ${elapsed}s (${steps} steps)\n`,
2524
+ );
2525
+ } catch (error) {
2526
+ const elapsed = ((Date.now() - start) / 1000).toFixed(1);
2527
+ const msg = error instanceof Error ? error.message : String(error);
2528
+ process.stderr.write(
2529
+ `[cron] ${jobName} failed after ${elapsed}s: ${msg}\n`,
2530
+ );
2531
+ }
2532
+ },
2533
+ );
2534
+ activeJobs.push(job);
2535
+ }
2536
+ process.stdout.write(
2537
+ `[cron] Scheduled ${entries.length} job${entries.length === 1 ? "" : "s"}: ${entries.map(([n]) => n).join(", ")}\n`,
2538
+ );
2539
+ };
2540
+
2541
+ const initialCronJobs = handler._cronJobs ?? {};
2542
+ scheduleCronJobs(initialCronJobs);
2543
+
2544
+ // Hot-reload cron config when AGENT.md changes
2545
+ const agentMdPath = resolve(workingDir, "AGENT.md");
2546
+ let reloadDebounce: ReturnType<typeof setTimeout> | null = null;
2547
+ const watcher = fsWatch(agentMdPath, () => {
2548
+ if (reloadDebounce) clearTimeout(reloadDebounce);
2549
+ reloadDebounce = setTimeout(async () => {
2550
+ try {
2551
+ const agentMd = await readFile(agentMdPath, "utf8");
2552
+ const parsed = parseAgentMarkdown(agentMd);
2553
+ const newJobs = parsed.frontmatter.cron ?? {};
2554
+ handler._cronJobs = newJobs;
2555
+ scheduleCronJobs(newJobs);
2556
+ process.stdout.write(`[cron] Reloaded: ${Object.keys(newJobs).length} jobs scheduled\n`);
2557
+ } catch {
2558
+ // Parse errors during editing are expected; ignore
2559
+ }
2560
+ }, 500);
2561
+ });
2562
+
2124
2563
  const shutdown = () => {
2564
+ watcher.close();
2565
+ for (const job of activeJobs) {
2566
+ job.stop();
2567
+ }
2125
2568
  server.close();
2126
- // Force-close any lingering connections so the port is freed immediately
2127
2569
  server.closeAllConnections?.();
2128
2570
  process.exit(0);
2129
2571
  };
@@ -2746,6 +3188,9 @@ export const buildTarget = async (
2746
3188
  options?: { force?: boolean },
2747
3189
  ): Promise<void> => {
2748
3190
  const normalizedTarget = normalizeDeployTarget(target);
3191
+ if (normalizedTarget === "vercel" && !options?.force) {
3192
+ await checkVercelCronDrift(workingDir);
3193
+ }
2749
3194
  const writtenPaths = await scaffoldDeployTarget(workingDir, normalizedTarget, {
2750
3195
  force: options?.force,
2751
3196
  });
@@ -126,6 +126,7 @@ export const consumeFirstRunIntro = async (
126
126
  "- **Enable auth**: Add bearer tokens or custom authentication",
127
127
  "- **Turn on telemetry**: Track usage with OpenTelemetry/OTLP",
128
128
  "- **Add MCP servers**: Connect external tool servers",
129
+ "- **Schedule cron jobs**: Set up recurring tasks in AGENT.md frontmatter",
129
130
  "",
130
131
  "Just let me know what you'd like to work on!\n",
131
132
  ].join("\n");
package/test/cli.test.ts CHANGED
@@ -11,7 +11,10 @@ import {
11
11
  initializeOnboardingMarker,
12
12
  } from "../src/init-feature-context.js";
13
13
 
14
- vi.mock("@poncho-ai/harness", () => ({
14
+ vi.mock("@poncho-ai/harness", async (importOriginal) => {
15
+ const actual = await importOriginal<typeof import("@poncho-ai/harness")>();
16
+ return {
17
+ parseAgentMarkdown: actual.parseAgentMarkdown,
15
18
  AgentHarness: class MockHarness {
16
19
  async initialize(): Promise<void> {}
17
20
  listTools(): Array<{ name: string; description: string }> {
@@ -160,7 +163,7 @@ vi.mock("@poncho-ai/harness", () => ({
160
163
  get: async () => Buffer.from(""),
161
164
  delete: async () => {},
162
165
  }),
163
- }));
166
+ };});
164
167
 
165
168
  import {
166
169
  buildTarget,
@@ -920,6 +923,43 @@ describe("cli", () => {
920
923
  expect(result.failed).toBe(0);
921
924
  });
922
925
 
926
+ it("includes crons in vercel.json when AGENT.md has cron jobs", async () => {
927
+ await initProject("cron-agent", { workingDir: tempDir });
928
+ const projectDir = join(tempDir, "cron-agent");
929
+ const agentMdPath = join(projectDir, "AGENT.md");
930
+ const agentMd = await readFile(agentMdPath, "utf8");
931
+ // Insert cron block before the closing --- of frontmatter
932
+ const updatedAgentMd = agentMd.replace(
933
+ /^(---\n[\s\S]*?)(---\n)/m,
934
+ `$1cron:\n daily-report:\n schedule: "0 9 * * *"\n task: "Generate the daily report"\n health-check:\n schedule: "*/30 * * * *"\n task: "Check all APIs"\n$2`,
935
+ );
936
+ await writeFile(agentMdPath, updatedAgentMd, "utf8");
937
+ await buildTarget(projectDir, "vercel", { force: true });
938
+ const vercelConfig = JSON.parse(
939
+ await readFile(join(projectDir, "vercel.json"), "utf8"),
940
+ ) as { crons?: Array<{ path: string; schedule: string }> };
941
+ expect(vercelConfig.crons).toBeDefined();
942
+ expect(vercelConfig.crons).toHaveLength(2);
943
+ expect(vercelConfig.crons).toContainEqual({
944
+ path: "/api/cron/daily-report",
945
+ schedule: "0 9 * * *",
946
+ });
947
+ expect(vercelConfig.crons).toContainEqual({
948
+ path: "/api/cron/health-check",
949
+ schedule: "*/30 * * * *",
950
+ });
951
+ });
952
+
953
+ it("omits crons from vercel.json when AGENT.md has no cron jobs", async () => {
954
+ await initProject("no-cron-agent", { workingDir: tempDir });
955
+ const projectDir = join(tempDir, "no-cron-agent");
956
+ await buildTarget(projectDir, "vercel", { force: true });
957
+ const vercelConfig = JSON.parse(
958
+ await readFile(join(projectDir, "vercel.json"), "utf8"),
959
+ ) as { crons?: unknown };
960
+ expect(vercelConfig.crons).toBeUndefined();
961
+ });
962
+
923
963
  it("fails on existing deploy files unless force is enabled", async () => {
924
964
  await initProject("collision-agent", { workingDir: tempDir });
925
965
  const projectDir = join(tempDir, "collision-agent");