@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/.turbo/turbo-build.log +6 -6
- package/CHANGELOG.md +24 -0
- package/dist/{chunk-T2F6ICXI.js → chunk-CUCEDHME.js} +542 -18
- package/dist/cli.js +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.js +1 -1
- package/dist/{run-interactive-ink-7FP5PT7Q.js → run-interactive-ink-VZBOYJYS.js} +1 -1
- package/package.json +5 -3
- package/src/index.ts +594 -19
- package/src/init-feature-context.ts +6 -0
- package/src/init-onboarding.ts +10 -2
- package/test/cli.test.ts +42 -2
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
|
-
|
|
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
|
});
|