@poncho-ai/cli 0.30.8 → 0.32.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
@@ -72,6 +72,12 @@ import {
72
72
  consumeFirstRunIntro,
73
73
  initializeOnboardingMarker,
74
74
  } from "./init-feature-context.js";
75
+ import {
76
+ exportOpenAICodex,
77
+ loginOpenAICodex,
78
+ logoutOpenAICodex,
79
+ statusOpenAICodex,
80
+ } from "./auth-codex.js";
75
81
 
76
82
  const __dirname = dirname(fileURLToPath(import.meta.url));
77
83
  const require = createRequire(import.meta.url);
@@ -280,7 +286,7 @@ const parseParams = (values: string[]): Record<string, string> => {
280
286
  const AGENT_TEMPLATE = (
281
287
  name: string,
282
288
  id: string,
283
- options: { modelProvider: "anthropic" | "openai"; modelName: string },
289
+ options: { modelProvider: "anthropic" | "openai" | "openai-codex"; modelName: string },
284
290
  ): string => `---
285
291
  name: ${name}
286
292
  id: ${id}
@@ -371,18 +377,25 @@ An AI agent built with [Poncho](https://github.com/cesr/poncho-ai).
371
377
 
372
378
  - Node.js 20+
373
379
  - npm (or pnpm/yarn)
374
- - Anthropic or OpenAI API key
380
+ - Anthropic API key, OpenAI API key, or OpenAI Codex OAuth refresh token
375
381
 
376
382
  ## Quick Start
377
383
 
378
384
  \`\`\`bash
379
385
  npm install
380
- # If you didn't enter an API key during init:
386
+ # If you didn't enter credentials during init:
381
387
  cp .env.example .env
382
- # Then edit .env and add your API key
388
+ # Then edit .env and add provider credentials
383
389
  poncho dev
384
390
  \`\`\`
385
391
 
392
+ For OpenAI Codex OAuth bootstrap:
393
+
394
+ \`\`\`bash
395
+ poncho auth login --provider openai-codex --device
396
+ poncho auth export --provider openai-codex --format env
397
+ \`\`\`
398
+
386
399
  Open \`http://localhost:3000\` for the web UI, or \`http://localhost:3000/api/docs\` for interactive API documentation.
387
400
 
388
401
  The web UI supports file attachments (drag-and-drop, paste, or attach button), conversation management (sidebar), a context window usage ring, and tool approval prompts. It can be installed as a PWA.
@@ -417,6 +430,11 @@ poncho test
417
430
  # List available tools
418
431
  poncho tools
419
432
 
433
+ # OpenAI Codex auth (OAuth subscription)
434
+ poncho auth login --provider openai-codex --device
435
+ poncho auth status --provider openai-codex
436
+ poncho auth export --provider openai-codex --format env
437
+
420
438
  # Remove deprecated guidance from AGENT.md after upgrading
421
439
  poncho update-agent
422
440
  \`\`\`
@@ -719,6 +737,11 @@ Set environment variables on your deployment platform:
719
737
 
720
738
  \`\`\`bash
721
739
  ANTHROPIC_API_KEY=sk-ant-... # Required
740
+ # OR for OpenAI API key provider:
741
+ # OPENAI_API_KEY=sk-...
742
+ # OR for OpenAI Codex OAuth provider:
743
+ # OPENAI_CODEX_REFRESH_TOKEN=rt_...
744
+ # OPENAI_CODEX_ACCOUNT_ID=... # Optional
722
745
  PONCHO_AUTH_TOKEN=your-secret # Optional: protect your endpoint
723
746
  PONCHO_MAX_DURATION=55 # Optional: serverless timeout in seconds (enables auto-continuation)
724
747
  PONCHO_INTERNAL_SECRET=... # Recommended on serverless: shared secret for internal callback auth
@@ -1696,7 +1719,7 @@ export const createRequestHandler = async (options?: {
1696
1719
  parentConversationId: string,
1697
1720
  task: string,
1698
1721
  ownerId: string,
1699
- isContinuation = false,
1722
+ _isContinuation = false,
1700
1723
  ): Promise<void> => {
1701
1724
  const childHarness = new AgentHarness({
1702
1725
  workingDir,
@@ -1732,14 +1755,12 @@ export const createRequestHandler = async (options?: {
1732
1755
  conversation.lastActivityAt = Date.now();
1733
1756
  await conversationStore.update(conversation);
1734
1757
 
1735
- const harnessMessages = isContinuation && conversation._continuationMessages?.length
1736
- ? [...conversation._continuationMessages]
1737
- : conversation._harnessMessages?.length
1738
- ? [...conversation._harnessMessages]
1739
- : [...conversation.messages];
1758
+ const harnessMessages = conversation._harnessMessages?.length
1759
+ ? [...conversation._harnessMessages]
1760
+ : [...conversation.messages];
1740
1761
 
1741
1762
  for await (const event of childHarness.runWithTelemetry({
1742
- task: isContinuation ? undefined : task,
1763
+ task,
1743
1764
  conversationId: childConversationId,
1744
1765
  parameters: {
1745
1766
  __activeConversationId: childConversationId,
@@ -1939,19 +1960,22 @@ export const createRequestHandler = async (options?: {
1939
1960
 
1940
1961
  const conv = await conversationStore.get(childConversationId);
1941
1962
  if (conv) {
1963
+ const hasContent = assistantResponse.length > 0 || toolTimeline.length > 0;
1964
+ // Always persist intermediate messages so progress is visible
1965
+ if (hasContent) {
1966
+ conv.messages.push({
1967
+ role: "assistant",
1968
+ content: assistantResponse,
1969
+ metadata: toolTimeline.length > 0 || sections.length > 0
1970
+ ? { toolActivity: toolTimeline, sections: sections.length > 0 ? sections : undefined } as Message["metadata"]
1971
+ : undefined,
1972
+ });
1973
+ }
1942
1974
  if (runResult?.continuation && runResult.continuationMessages) {
1943
1975
  conv._continuationMessages = runResult.continuationMessages;
1944
1976
  } else {
1945
1977
  conv._continuationMessages = undefined;
1946
- if (assistantResponse.length > 0 || toolTimeline.length > 0) {
1947
- conv.messages.push({
1948
- role: "assistant",
1949
- content: assistantResponse,
1950
- metadata: toolTimeline.length > 0 || sections.length > 0
1951
- ? { toolActivity: toolTimeline, sections: sections.length > 0 ? sections : undefined } as Message["metadata"]
1952
- : undefined,
1953
- });
1954
- }
1978
+ conv._continuationCount = undefined;
1955
1979
  }
1956
1980
  if (runResult?.continuationMessages) {
1957
1981
  conv._harnessMessages = runResult.continuationMessages;
@@ -1966,16 +1990,11 @@ export const createRequestHandler = async (options?: {
1966
1990
  activeConversationRuns.delete(childConversationId);
1967
1991
  try { await childHarness.shutdown(); } catch {}
1968
1992
 
1969
- if (isServerless) {
1970
- const work = selfFetchWithRetry(`/api/internal/subagent/${encodeURIComponent(childConversationId)}/run`, { continuation: true }).catch(err =>
1971
- console.error(`[poncho][subagent] Continuation self-fetch failed:`, err instanceof Error ? err.message : err),
1972
- );
1973
- doWaitUntil(work);
1974
- } else {
1975
- runSubagent(childConversationId, parentConversationId, task, ownerId, true).catch(err =>
1976
- console.error(`[poncho][subagent] Continuation failed:`, err instanceof Error ? err.message : err),
1977
- );
1978
- }
1993
+ const work = selfFetchWithRetry(`/api/internal/continue/${encodeURIComponent(childConversationId)}`).catch(err =>
1994
+ console.error(`[poncho][subagent] Continuation self-fetch failed:`, err instanceof Error ? err.message : err),
1995
+ );
1996
+ doWaitUntil(work);
1997
+ if (!waitUntilHook) await work;
1979
1998
  return;
1980
1999
  }
1981
2000
 
@@ -3187,6 +3206,413 @@ export const createRequestHandler = async (options?: {
3187
3206
  return typeof headerValue === "string" && headerValue === internalSecret;
3188
3207
  };
3189
3208
 
3209
+ // ── Unified continuation ──────────────────────────────────────────────
3210
+ const MAX_CONTINUATION_COUNT = 20;
3211
+
3212
+ async function* runContinuation(
3213
+ conversationId: string,
3214
+ ): AsyncGenerator<AgentEvent> {
3215
+ const conversation = await conversationStore.get(conversationId);
3216
+ if (!conversation) return;
3217
+ if (!conversation._continuationMessages?.length) return;
3218
+ if (conversation.runStatus === "running") return;
3219
+
3220
+ const count = (conversation._continuationCount ?? 0) + 1;
3221
+ if (count > MAX_CONTINUATION_COUNT) {
3222
+ console.warn(`[poncho][continuation] Max continuation count (${MAX_CONTINUATION_COUNT}) reached for ${conversationId}`);
3223
+ conversation._continuationMessages = undefined;
3224
+ conversation._continuationCount = undefined;
3225
+ await conversationStore.update(conversation);
3226
+ return;
3227
+ }
3228
+
3229
+ const continuationMessages = [...conversation._continuationMessages];
3230
+ conversation._continuationMessages = undefined;
3231
+ conversation._continuationCount = count;
3232
+ conversation.runStatus = "running";
3233
+ await conversationStore.update(conversation);
3234
+
3235
+ const abortController = new AbortController();
3236
+ activeConversationRuns.set(conversationId, {
3237
+ ownerId: conversation.ownerId,
3238
+ abortController,
3239
+ runId: null,
3240
+ });
3241
+
3242
+ const prevStream = conversationEventStreams.get(conversationId);
3243
+ if (prevStream) {
3244
+ prevStream.finished = false;
3245
+ prevStream.buffer = [];
3246
+ } else {
3247
+ conversationEventStreams.set(conversationId, {
3248
+ buffer: [],
3249
+ subscribers: new Set(),
3250
+ finished: false,
3251
+ });
3252
+ }
3253
+
3254
+ try {
3255
+ if (conversation.parentConversationId) {
3256
+ yield* runSubagentContinuation(conversationId, conversation, continuationMessages);
3257
+ } else {
3258
+ yield* runChatContinuation(conversationId, conversation, continuationMessages);
3259
+ }
3260
+ } finally {
3261
+ activeConversationRuns.delete(conversationId);
3262
+ finishConversationStream(conversationId);
3263
+ }
3264
+ }
3265
+
3266
+ async function* runChatContinuation(
3267
+ conversationId: string,
3268
+ conversation: Conversation,
3269
+ continuationMessages: Message[],
3270
+ ): AsyncGenerator<AgentEvent> {
3271
+ let assistantResponse = "";
3272
+ let latestRunId = conversation.runtimeRunId ?? "";
3273
+ const toolTimeline: string[] = [];
3274
+ const sections: Array<{ type: "text" | "tools"; content: string | string[] }> = [];
3275
+ let currentTools: string[] = [];
3276
+ let currentText = "";
3277
+ let runContextTokens = conversation.contextTokens ?? 0;
3278
+ let runContextWindow = conversation.contextWindow ?? 0;
3279
+ let nextContinuationMessages: Message[] | undefined;
3280
+ let nextHarnessMessages: Message[] | undefined;
3281
+
3282
+ for await (const event of harness.runWithTelemetry({
3283
+ conversationId,
3284
+ parameters: {
3285
+ __activeConversationId: conversationId,
3286
+ __ownerId: conversation.ownerId,
3287
+ },
3288
+ messages: continuationMessages,
3289
+ abortSignal: activeConversationRuns.get(conversationId)?.abortController.signal,
3290
+ })) {
3291
+ if (event.type === "run:started") {
3292
+ latestRunId = event.runId;
3293
+ runOwners.set(event.runId, conversation.ownerId);
3294
+ runConversations.set(event.runId, conversationId);
3295
+ const active = activeConversationRuns.get(conversationId);
3296
+ if (active) active.runId = event.runId;
3297
+ }
3298
+ if (event.type === "model:chunk") {
3299
+ if (currentTools.length > 0) {
3300
+ sections.push({ type: "tools", content: currentTools });
3301
+ currentTools = [];
3302
+ if (assistantResponse.length > 0 && !/\s$/.test(assistantResponse)) {
3303
+ assistantResponse += " ";
3304
+ }
3305
+ }
3306
+ assistantResponse += event.content;
3307
+ currentText += event.content;
3308
+ }
3309
+ if (event.type === "tool:started") {
3310
+ if (currentText.length > 0) {
3311
+ sections.push({ type: "text", content: currentText });
3312
+ currentText = "";
3313
+ }
3314
+ const toolText = `- start \`${event.tool}\``;
3315
+ toolTimeline.push(toolText);
3316
+ currentTools.push(toolText);
3317
+ }
3318
+ if (event.type === "tool:completed") {
3319
+ const toolText = `- done \`${event.tool}\` (${event.duration}ms)`;
3320
+ toolTimeline.push(toolText);
3321
+ currentTools.push(toolText);
3322
+ }
3323
+ if (event.type === "tool:error") {
3324
+ const toolText = `- error \`${event.tool}\`: ${event.error}`;
3325
+ toolTimeline.push(toolText);
3326
+ currentTools.push(toolText);
3327
+ }
3328
+ if (event.type === "run:completed") {
3329
+ runContextTokens = event.result.contextTokens ?? runContextTokens;
3330
+ runContextWindow = event.result.contextWindow ?? runContextWindow;
3331
+ if (event.result.continuation && event.result.continuationMessages) {
3332
+ nextContinuationMessages = event.result.continuationMessages;
3333
+ }
3334
+ if (event.result.continuationMessages) {
3335
+ nextHarnessMessages = event.result.continuationMessages;
3336
+ }
3337
+ if (!assistantResponse && event.result.response) {
3338
+ assistantResponse = event.result.response;
3339
+ }
3340
+ }
3341
+ if (event.type === "run:error") {
3342
+ assistantResponse = assistantResponse || `[Error: ${event.error.message}]`;
3343
+ }
3344
+ await telemetry.emit(event);
3345
+ broadcastEvent(conversationId, event);
3346
+ yield event;
3347
+ }
3348
+
3349
+ if (currentTools.length > 0) sections.push({ type: "tools", content: currentTools });
3350
+ if (currentText.length > 0) sections.push({ type: "text", content: currentText });
3351
+
3352
+ const freshConv = await conversationStore.get(conversationId);
3353
+ if (!freshConv) return;
3354
+
3355
+ const hasContent = assistantResponse.length > 0 || toolTimeline.length > 0;
3356
+ const assistantMetadata =
3357
+ toolTimeline.length > 0 || sections.length > 0
3358
+ ? ({
3359
+ toolActivity: [...toolTimeline],
3360
+ sections: sections.length > 0 ? sections : undefined,
3361
+ } as Message["metadata"])
3362
+ : undefined;
3363
+
3364
+ if (nextContinuationMessages) {
3365
+ if (hasContent) {
3366
+ freshConv.messages = [
3367
+ ...freshConv.messages,
3368
+ { role: "assistant", content: assistantResponse, metadata: assistantMetadata },
3369
+ ];
3370
+ }
3371
+ freshConv._continuationMessages = nextContinuationMessages;
3372
+ freshConv._continuationCount = conversation._continuationCount;
3373
+ } else {
3374
+ if (hasContent) {
3375
+ freshConv.messages = [
3376
+ ...freshConv.messages,
3377
+ { role: "assistant", content: assistantResponse, metadata: assistantMetadata },
3378
+ ];
3379
+ }
3380
+ freshConv._continuationMessages = undefined;
3381
+ freshConv._continuationCount = undefined;
3382
+ }
3383
+
3384
+ if (nextHarnessMessages) freshConv._harnessMessages = nextHarnessMessages;
3385
+ freshConv.runtimeRunId = latestRunId || freshConv.runtimeRunId;
3386
+ freshConv.pendingApprovals = [];
3387
+ if (runContextTokens > 0) freshConv.contextTokens = runContextTokens;
3388
+ if (runContextWindow > 0) freshConv.contextWindow = runContextWindow;
3389
+ freshConv.runStatus = "idle";
3390
+ freshConv.updatedAt = Date.now();
3391
+ await conversationStore.update(freshConv);
3392
+ }
3393
+
3394
+ async function* runSubagentContinuation(
3395
+ conversationId: string,
3396
+ conversation: Conversation,
3397
+ continuationMessages: Message[],
3398
+ ): AsyncGenerator<AgentEvent> {
3399
+ const parentConversationId = conversation.parentConversationId!;
3400
+ const task = conversation.subagentMeta?.task ?? "";
3401
+ const ownerId = conversation.ownerId;
3402
+
3403
+ const childHarness = new AgentHarness({
3404
+ workingDir,
3405
+ environment: resolveHarnessEnvironment(),
3406
+ uploadStore,
3407
+ });
3408
+ await childHarness.initialize();
3409
+ childHarness.unregisterTools(["memory_main_write", "memory_main_edit"]);
3410
+
3411
+ const childAbortController = activeConversationRuns.get(conversationId)?.abortController ?? new AbortController();
3412
+ activeSubagentRuns.set(conversationId, { abortController: childAbortController, harness: childHarness, parentConversationId });
3413
+
3414
+ let assistantResponse = "";
3415
+ let latestRunId = "";
3416
+ let runResult: { status: string; response?: string; steps: number; duration: number; continuation?: boolean; continuationMessages?: Message[] } | undefined;
3417
+ const toolTimeline: string[] = [];
3418
+ const sections: Array<{ type: "text" | "tools"; content: string | string[] }> = [];
3419
+ let currentTools: string[] = [];
3420
+ let currentText = "";
3421
+
3422
+ try {
3423
+ for await (const event of childHarness.runWithTelemetry({
3424
+ conversationId,
3425
+ parameters: {
3426
+ __activeConversationId: conversationId,
3427
+ __ownerId: ownerId,
3428
+ },
3429
+ messages: continuationMessages,
3430
+ abortSignal: childAbortController.signal,
3431
+ })) {
3432
+ if (event.type === "run:started") {
3433
+ latestRunId = event.runId;
3434
+ const active = activeConversationRuns.get(conversationId);
3435
+ if (active) active.runId = event.runId;
3436
+ }
3437
+ if (event.type === "model:chunk") {
3438
+ if (currentTools.length > 0) {
3439
+ sections.push({ type: "tools", content: currentTools });
3440
+ currentTools = [];
3441
+ if (assistantResponse.length > 0 && !/\s$/.test(assistantResponse)) {
3442
+ assistantResponse += " ";
3443
+ }
3444
+ }
3445
+ assistantResponse += event.content;
3446
+ currentText += event.content;
3447
+ }
3448
+ if (event.type === "tool:started") {
3449
+ if (currentText.length > 0) {
3450
+ sections.push({ type: "text", content: currentText });
3451
+ currentText = "";
3452
+ }
3453
+ const toolText = `- start \`${event.tool}\``;
3454
+ toolTimeline.push(toolText);
3455
+ currentTools.push(toolText);
3456
+ }
3457
+ if (event.type === "tool:completed") {
3458
+ const toolText = `- done \`${event.tool}\` (${event.duration}ms)`;
3459
+ toolTimeline.push(toolText);
3460
+ currentTools.push(toolText);
3461
+ }
3462
+ if (event.type === "tool:error") {
3463
+ const toolText = `- error \`${event.tool}\`: ${event.error}`;
3464
+ toolTimeline.push(toolText);
3465
+ currentTools.push(toolText);
3466
+ }
3467
+ if (event.type === "run:completed") {
3468
+ runResult = {
3469
+ status: event.result.status,
3470
+ response: event.result.response,
3471
+ steps: event.result.steps,
3472
+ duration: event.result.duration,
3473
+ continuation: event.result.continuation,
3474
+ continuationMessages: event.result.continuationMessages,
3475
+ };
3476
+ if (!assistantResponse && event.result.response) {
3477
+ assistantResponse = event.result.response;
3478
+ }
3479
+ }
3480
+ if (event.type === "run:error") {
3481
+ assistantResponse = assistantResponse || `[Error: ${event.error.message}]`;
3482
+ }
3483
+ broadcastEvent(conversationId, event);
3484
+ yield event;
3485
+ }
3486
+
3487
+ if (currentTools.length > 0) sections.push({ type: "tools", content: currentTools });
3488
+ if (currentText.length > 0) sections.push({ type: "text", content: currentText });
3489
+
3490
+ const conv = await conversationStore.get(conversationId);
3491
+ if (conv) {
3492
+ const hasContent = assistantResponse.length > 0 || toolTimeline.length > 0;
3493
+ if (runResult?.continuation && runResult.continuationMessages) {
3494
+ if (hasContent) {
3495
+ conv.messages.push({
3496
+ role: "assistant",
3497
+ content: assistantResponse,
3498
+ metadata: toolTimeline.length > 0 || sections.length > 0
3499
+ ? { toolActivity: toolTimeline, sections: sections.length > 0 ? sections : undefined } as Message["metadata"]
3500
+ : undefined,
3501
+ });
3502
+ }
3503
+ conv._continuationMessages = runResult.continuationMessages;
3504
+ conv._continuationCount = conversation._continuationCount;
3505
+ } else {
3506
+ conv._continuationMessages = undefined;
3507
+ conv._continuationCount = undefined;
3508
+ if (hasContent) {
3509
+ conv.messages.push({
3510
+ role: "assistant",
3511
+ content: assistantResponse,
3512
+ metadata: toolTimeline.length > 0 || sections.length > 0
3513
+ ? { toolActivity: toolTimeline, sections: sections.length > 0 ? sections : undefined } as Message["metadata"]
3514
+ : undefined,
3515
+ });
3516
+ }
3517
+ }
3518
+ if (runResult?.continuationMessages) {
3519
+ conv._harnessMessages = runResult.continuationMessages;
3520
+ }
3521
+ conv.lastActivityAt = Date.now();
3522
+ conv.runStatus = "idle";
3523
+ conv.updatedAt = Date.now();
3524
+
3525
+ if (runResult?.continuation) {
3526
+ await conversationStore.update(conv);
3527
+ activeSubagentRuns.delete(conversationId);
3528
+ try { await childHarness.shutdown(); } catch {}
3529
+ return;
3530
+ }
3531
+
3532
+ conv.subagentMeta = { ...conv.subagentMeta!, status: "completed" };
3533
+ await conversationStore.update(conv);
3534
+ }
3535
+
3536
+ activeSubagentRuns.delete(conversationId);
3537
+ broadcastEvent(parentConversationId, {
3538
+ type: "subagent:completed",
3539
+ subagentId: conversationId,
3540
+ conversationId,
3541
+ });
3542
+
3543
+ let subagentResponse = runResult?.response ?? assistantResponse;
3544
+ if (!subagentResponse) {
3545
+ const freshSubConv = await conversationStore.get(conversationId);
3546
+ if (freshSubConv) {
3547
+ const lastAssistant = [...freshSubConv.messages].reverse().find(m => m.role === "assistant");
3548
+ if (lastAssistant) {
3549
+ subagentResponse = typeof lastAssistant.content === "string" ? lastAssistant.content : "";
3550
+ }
3551
+ }
3552
+ }
3553
+
3554
+ const parentConv = await conversationStore.get(parentConversationId);
3555
+ if (parentConv) {
3556
+ const result: PendingSubagentResult = {
3557
+ subagentId: conversationId,
3558
+ task,
3559
+ status: "completed",
3560
+ result: { status: "completed", response: subagentResponse, steps: runResult?.steps ?? 0, tokens: { input: 0, output: 0, cached: 0 }, duration: runResult?.duration ?? 0 },
3561
+ timestamp: Date.now(),
3562
+ };
3563
+ await conversationStore.appendSubagentResult(parentConversationId, result);
3564
+
3565
+ if (isServerless) {
3566
+ selfFetchWithRetry(`/api/internal/conversations/${encodeURIComponent(parentConversationId)}/subagent-callback`).catch(err =>
3567
+ console.error(`[poncho][subagent] Callback self-fetch failed:`, err instanceof Error ? err.message : err),
3568
+ );
3569
+ } else {
3570
+ processSubagentCallback(parentConversationId).catch(err =>
3571
+ console.error(`[poncho][subagent] Callback failed:`, err instanceof Error ? err.message : err),
3572
+ );
3573
+ }
3574
+ }
3575
+
3576
+ try { await childHarness.shutdown(); } catch {}
3577
+ } catch (err) {
3578
+ activeSubagentRuns.delete(conversationId);
3579
+ try { await childHarness.shutdown(); } catch {}
3580
+
3581
+ const conv = await conversationStore.get(conversationId);
3582
+ if (conv) {
3583
+ conv.subagentMeta = { ...conv.subagentMeta!, status: "error", error: { code: "CONTINUATION_ERROR", message: err instanceof Error ? err.message : String(err) } };
3584
+ conv.runStatus = "idle";
3585
+ conv._continuationMessages = undefined;
3586
+ conv._continuationCount = undefined;
3587
+ conv.updatedAt = Date.now();
3588
+ await conversationStore.update(conv);
3589
+ }
3590
+
3591
+ broadcastEvent(conversation.parentConversationId!, {
3592
+ type: "subagent:completed",
3593
+ subagentId: conversationId,
3594
+ conversationId,
3595
+ });
3596
+
3597
+ const parentConv = await conversationStore.get(conversation.parentConversationId!);
3598
+ if (parentConv) {
3599
+ const result: PendingSubagentResult = {
3600
+ subagentId: conversationId,
3601
+ task,
3602
+ status: "error",
3603
+ error: { code: "CONTINUATION_ERROR", message: err instanceof Error ? err.message : String(err) },
3604
+ timestamp: Date.now(),
3605
+ };
3606
+ await conversationStore.appendSubagentResult(conversation.parentConversationId!, result);
3607
+ if (isServerless) {
3608
+ selfFetchWithRetry(`/api/internal/conversations/${encodeURIComponent(conversation.parentConversationId!)}/subagent-callback`).catch(() => {});
3609
+ } else {
3610
+ processSubagentCallback(conversation.parentConversationId!).catch(() => {});
3611
+ }
3612
+ }
3613
+ }
3614
+ }
3615
+
3190
3616
  const messagingAdapters = new Map<string, MessagingAdapter>();
3191
3617
  const messagingBridges: AgentBridge[] = [];
3192
3618
  if (config?.messaging && config.messaging.length > 0) {
@@ -3563,7 +3989,7 @@ export const createRequestHandler = async (options?: {
3563
3989
  const subagentRunMatch = pathname.match(/^\/api\/internal\/subagent\/([^/]+)\/run$/);
3564
3990
  if (subagentRunMatch) {
3565
3991
  const subagentId = decodeURIComponent(subagentRunMatch[1]!);
3566
- const body = (await readRequestBody(request)) as { continuation?: boolean; resume?: boolean } | undefined;
3992
+ const body = (await readRequestBody(request)) as { resume?: boolean } | undefined;
3567
3993
  writeJson(response, 202, { ok: true });
3568
3994
  const work = (async () => {
3569
3995
  try {
@@ -3576,9 +4002,8 @@ export const createRequestHandler = async (options?: {
3576
4002
  return;
3577
4003
  }
3578
4004
 
3579
- const isContinuation = body?.continuation === true;
3580
- const task = isContinuation ? conv.subagentMeta?.task ?? "" : (conv.messages.find(m => m.role === "user")?.content as string) ?? conv.subagentMeta?.task ?? "";
3581
- await runSubagent(subagentId, conv.parentConversationId, task, conv.ownerId, isContinuation);
4005
+ const task = (conv.messages.find(m => m.role === "user")?.content as string) ?? conv.subagentMeta?.task ?? "";
4006
+ await runSubagent(subagentId, conv.parentConversationId, task, conv.ownerId, false);
3582
4007
  } catch (err) {
3583
4008
  console.error(`[poncho][internal] subagent run error for ${subagentId}:`, err instanceof Error ? err.message : err);
3584
4009
  }
@@ -3598,6 +4023,29 @@ export const createRequestHandler = async (options?: {
3598
4023
  return;
3599
4024
  }
3600
4025
 
4026
+ const continueMatch = pathname.match(/^\/api\/internal\/continue\/([^/]+)$/);
4027
+ if (continueMatch) {
4028
+ const conversationId = decodeURIComponent(continueMatch[1]!);
4029
+ writeJson(response, 202, { ok: true });
4030
+ const work = (async () => {
4031
+ try {
4032
+ for await (const _event of runContinuation(conversationId)) {
4033
+ // Events are already broadcast inside runContinuation
4034
+ }
4035
+ // Chain: if another continuation is needed, fire next self-fetch
4036
+ const conv = await conversationStore.get(conversationId);
4037
+ if (conv?._continuationMessages?.length) {
4038
+ await selfFetchWithRetry(`/api/internal/continue/${encodeURIComponent(conversationId)}`);
4039
+ }
4040
+ } catch (err) {
4041
+ console.error(`[poncho][internal-continue] Error for ${conversationId}:`, err instanceof Error ? err.message : err);
4042
+ }
4043
+ })();
4044
+ doWaitUntil(work);
4045
+ if (!waitUntilHook) await work;
4046
+ return;
4047
+ }
4048
+
3601
4049
  writeJson(response, 404, { error: "Not found" });
3602
4050
  return;
3603
4051
  }
@@ -4489,6 +4937,88 @@ export const createRequestHandler = async (options?: {
4489
4937
  return;
4490
4938
  }
4491
4939
 
4940
+ // ── Public continuation endpoint (SSE) ──
4941
+ const conversationContinueMatch = pathname.match(/^\/api\/conversations\/([^/]+)\/continue$/);
4942
+ if (conversationContinueMatch && request.method === "POST") {
4943
+ const conversationId = decodeURIComponent(conversationContinueMatch[1] ?? "");
4944
+ const conversation = await conversationStore.get(conversationId);
4945
+ if (!conversation || conversation.ownerId !== ownerId) {
4946
+ writeJson(response, 404, {
4947
+ code: "CONVERSATION_NOT_FOUND",
4948
+ message: "Conversation not found",
4949
+ });
4950
+ return;
4951
+ }
4952
+ if (conversation.parentConversationId) {
4953
+ writeJson(response, 403, {
4954
+ code: "SUBAGENT_READ_ONLY",
4955
+ message: "Subagent conversations are read-only.",
4956
+ });
4957
+ return;
4958
+ }
4959
+
4960
+ response.writeHead(200, {
4961
+ "Content-Type": "text/event-stream",
4962
+ "Cache-Control": "no-cache",
4963
+ Connection: "keep-alive",
4964
+ "X-Accel-Buffering": "no",
4965
+ });
4966
+
4967
+ const unsubSubagentEvents = onConversationEvent(conversationId, (evt) => {
4968
+ if (evt.type.startsWith("subagent:")) {
4969
+ try { response.write(formatSseEvent(evt)); } catch {}
4970
+ }
4971
+ });
4972
+
4973
+ let eventCount = 0;
4974
+ try {
4975
+ for await (const event of runContinuation(conversationId)) {
4976
+ eventCount++;
4977
+ let sseEvent: AgentEvent = event;
4978
+ if (sseEvent.type === "run:completed") {
4979
+ const hasRunningSubagents = Array.from(activeSubagentRuns.values()).some(
4980
+ r => r.parentConversationId === conversationId,
4981
+ );
4982
+ const stripped = { ...sseEvent, result: { ...sseEvent.result, continuationMessages: undefined } };
4983
+ sseEvent = hasRunningSubagents ? { ...stripped, pendingSubagents: true } : stripped;
4984
+ }
4985
+ try {
4986
+ response.write(formatSseEvent(sseEvent));
4987
+ } catch {
4988
+ // Client disconnected — continue processing so the run completes
4989
+ }
4990
+ emitBrowserStatusIfActive(conversationId, event, response);
4991
+ }
4992
+ } catch (err) {
4993
+ const errorEvent: AgentEvent = {
4994
+ type: "run:error",
4995
+ runId: "",
4996
+ error: { code: "CONTINUATION_ERROR", message: err instanceof Error ? err.message : String(err) },
4997
+ };
4998
+ try { response.write(formatSseEvent(errorEvent)); } catch {}
4999
+ } finally {
5000
+ unsubSubagentEvents();
5001
+ }
5002
+
5003
+ if (eventCount === 0) {
5004
+ try { response.write("event: stream:end\ndata: {}\n\n"); } catch {}
5005
+ } else {
5006
+ // If the run produced events and another continuation is needed,
5007
+ // fire a delayed safety net in case the client disconnects before
5008
+ // POSTing the next /continue.
5009
+ const freshConv = await conversationStore.get(conversationId);
5010
+ if (freshConv?._continuationMessages?.length) {
5011
+ doWaitUntil(
5012
+ new Promise(r => setTimeout(r, 3000)).then(() =>
5013
+ selfFetchWithRetry(`/api/internal/continue/${encodeURIComponent(conversationId)}`),
5014
+ ),
5015
+ );
5016
+ }
5017
+ }
5018
+ response.end();
5019
+ return;
5020
+ }
5021
+
4492
5022
  const conversationMessageMatch = pathname.match(/^\/api\/conversations\/([^/]+)\/messages$/);
4493
5023
  if (conversationMessageMatch && request.method === "POST") {
4494
5024
  const conversationId = decodeURIComponent(conversationMessageMatch[1] ?? "");
@@ -4510,7 +5040,6 @@ export const createRequestHandler = async (options?: {
4510
5040
  let messageText = "";
4511
5041
  let bodyParameters: Record<string, unknown> | undefined;
4512
5042
  let files: FileInput[] = [];
4513
- let isContinuation = false;
4514
5043
 
4515
5044
  const contentType = request.headers["content-type"] ?? "";
4516
5045
  if (contentType.includes("multipart/form-data")) {
@@ -4521,15 +5050,10 @@ export const createRequestHandler = async (options?: {
4521
5050
  } else {
4522
5051
  const body = (await readRequestBody(request)) as {
4523
5052
  message?: string;
4524
- continuation?: boolean;
4525
5053
  parameters?: Record<string, unknown>;
4526
5054
  files?: Array<{ data?: string; mediaType?: string; filename?: string }>;
4527
5055
  };
4528
- if (body.continuation === true) {
4529
- isContinuation = true;
4530
- } else {
4531
- messageText = body.message?.trim() ?? "";
4532
- }
5056
+ messageText = body.message?.trim() ?? "";
4533
5057
  bodyParameters = body.parameters;
4534
5058
  if (Array.isArray(body.files)) {
4535
5059
  files = body.files
@@ -4538,7 +5062,7 @@ export const createRequestHandler = async (options?: {
4538
5062
  );
4539
5063
  }
4540
5064
  }
4541
- if (!isContinuation && !messageText) {
5065
+ if (!messageText) {
4542
5066
  writeJson(response, 400, {
4543
5067
  code: "VALIDATION_ERROR",
4544
5068
  message: "message is required",
@@ -4564,7 +5088,6 @@ export const createRequestHandler = async (options?: {
4564
5088
  runId: null,
4565
5089
  });
4566
5090
  if (
4567
- !isContinuation &&
4568
5091
  conversation.messages.length === 0 &&
4569
5092
  (conversation.title === "New conversation" || conversation.title.trim().length === 0)
4570
5093
  ) {
@@ -4576,11 +5099,9 @@ export const createRequestHandler = async (options?: {
4576
5099
  Connection: "keep-alive",
4577
5100
  "X-Accel-Buffering": "no",
4578
5101
  });
4579
- const harnessMessages = isContinuation && conversation._continuationMessages?.length
4580
- ? [...conversation._continuationMessages]
4581
- : conversation._harnessMessages?.length
4582
- ? [...conversation._harnessMessages]
4583
- : [...conversation.messages];
5102
+ const harnessMessages = conversation._harnessMessages?.length
5103
+ ? [...conversation._harnessMessages]
5104
+ : [...conversation.messages];
4584
5105
  const historyMessages = [...conversation.messages];
4585
5106
  const preRunMessages = [...conversation.messages];
4586
5107
  let latestRunId = conversation.runtimeRunId ?? "";
@@ -4596,8 +5117,8 @@ export const createRequestHandler = async (options?: {
4596
5117
  let runContextWindow = conversation.contextWindow ?? 0;
4597
5118
  let runContinuationMessages: Message[] | undefined;
4598
5119
  let runHarnessMessages: Message[] | undefined;
4599
- let userContent: Message["content"] | undefined = isContinuation ? undefined : messageText;
4600
- if (!isContinuation && files.length > 0) {
5120
+ let userContent: Message["content"] | undefined = messageText;
5121
+ if (files.length > 0) {
4601
5122
  try {
4602
5123
  const uploadedParts = await Promise.all(
4603
5124
  files.map(async (f) => {
@@ -4640,9 +5161,10 @@ export const createRequestHandler = async (options?: {
4640
5161
  });
4641
5162
 
4642
5163
  try {
4643
- if (!isContinuation) {
5164
+ {
4644
5165
  conversation.messages = [...historyMessages, { role: "user", content: userContent! }];
4645
5166
  conversation.subagentCallbackCount = 0;
5167
+ conversation._continuationCount = undefined;
4646
5168
  conversation.updatedAt = Date.now();
4647
5169
  conversationStore.update(conversation).catch((err) => {
4648
5170
  console.error("[poncho] Failed to persist user turn:", err);
@@ -4720,7 +5242,7 @@ export const createRequestHandler = async (options?: {
4720
5242
  };
4721
5243
 
4722
5244
  for await (const event of harness.runWithTelemetry({
4723
- task: isContinuation ? undefined : messageText,
5245
+ task: messageText,
4724
5246
  conversationId,
4725
5247
  parameters: {
4726
5248
  ...(bodyParameters ?? {}),
@@ -4729,7 +5251,7 @@ export const createRequestHandler = async (options?: {
4729
5251
  __ownerId: ownerId,
4730
5252
  },
4731
5253
  messages: harnessMessages,
4732
- files: !isContinuation && files.length > 0 ? files : undefined,
5254
+ files: files.length > 0 ? files : undefined,
4733
5255
  abortSignal: abortController.signal,
4734
5256
  })) {
4735
5257
  if (event.type === "run:started") {
@@ -4845,6 +5367,21 @@ export const createRequestHandler = async (options?: {
4845
5367
  }
4846
5368
  if (event.result.continuation && event.result.continuationMessages) {
4847
5369
  runContinuationMessages = event.result.continuationMessages;
5370
+
5371
+ // Persist intermediate messages so clients connecting later
5372
+ // see progress, plus _continuationMessages for the next step.
5373
+ const intSections = [...sections];
5374
+ if (currentTools.length > 0) intSections.push({ type: "tools", content: [...currentTools] });
5375
+ if (currentText.length > 0) intSections.push({ type: "text", content: currentText });
5376
+ const hasContent = assistantResponse.length > 0 || toolTimeline.length > 0 || intSections.length > 0;
5377
+ const intMetadata = toolTimeline.length > 0 || intSections.length > 0
5378
+ ? ({ toolActivity: [...toolTimeline], sections: intSections.length > 0 ? intSections : undefined } as Message["metadata"])
5379
+ : undefined;
5380
+ conversation.messages = [
5381
+ ...historyMessages,
5382
+ ...(userContent != null ? [{ role: "user" as const, content: userContent }] : []),
5383
+ ...(hasContent ? [{ role: "assistant" as const, content: assistantResponse, metadata: intMetadata }] : []),
5384
+ ];
4848
5385
  conversation._continuationMessages = runContinuationMessages;
4849
5386
  conversation._harnessMessages = runContinuationMessages;
4850
5387
  conversation.runtimeRunId = latestRunId || conversation.runtimeRunId;
@@ -4853,6 +5390,14 @@ export const createRequestHandler = async (options?: {
4853
5390
  if (runContextWindow > 0) conversation.contextWindow = runContextWindow;
4854
5391
  conversation.updatedAt = Date.now();
4855
5392
  await conversationStore.update(conversation);
5393
+
5394
+ // Delayed safety net: if the client doesn't POST to /continue
5395
+ // within 3 seconds (e.g. browser closed), the server picks it up.
5396
+ doWaitUntil(
5397
+ new Promise(r => setTimeout(r, 3000)).then(() =>
5398
+ selfFetchWithRetry(`/api/internal/continue/${encodeURIComponent(conversationId)}`),
5399
+ ),
5400
+ );
4856
5401
  }
4857
5402
  }
4858
5403
  await telemetry.emit(event);
@@ -5032,23 +5577,11 @@ export const createRequestHandler = async (options?: {
5032
5577
  }
5033
5578
 
5034
5579
  const urlObj = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
5035
- const continueConversationId = urlObj.searchParams.get("continue");
5036
- const continuationCount = Number(urlObj.searchParams.get("continuation") ?? "0");
5037
- const maxContinuations = 5;
5038
-
5039
- if (continuationCount >= maxContinuations) {
5040
- writeJson(response, 200, {
5041
- conversationId: continueConversationId,
5042
- status: "max_continuations_reached",
5043
- continuations: continuationCount,
5044
- });
5045
- return;
5046
- }
5047
5580
 
5048
5581
  const cronOwnerId = ownerId;
5049
5582
  const start = Date.now();
5050
5583
 
5051
- if (cronJob.channel && !continueConversationId) {
5584
+ if (cronJob.channel) {
5052
5585
  const adapter = messagingAdapters.get(cronJob.channel);
5053
5586
  if (!adapter) {
5054
5587
  writeJson(response, 200, {
@@ -5161,34 +5694,12 @@ export const createRequestHandler = async (options?: {
5161
5694
  }
5162
5695
 
5163
5696
  try {
5164
- let conversation;
5165
- let historyMessages: Message[] = [];
5166
-
5167
- if (continueConversationId) {
5168
- conversation = await conversationStore.get(continueConversationId);
5169
- if (!conversation) {
5170
- writeJson(response, 404, {
5171
- code: "CONVERSATION_NOT_FOUND",
5172
- message: "Continuation conversation not found",
5173
- });
5174
- return;
5175
- }
5176
- historyMessages = conversation._continuationMessages?.length
5177
- ? [...conversation._continuationMessages]
5178
- : conversation._harnessMessages?.length
5179
- ? [...conversation._harnessMessages]
5180
- : [...conversation.messages];
5181
- if (conversation._continuationMessages?.length) {
5182
- conversation._continuationMessages = undefined;
5183
- await conversationStore.update(conversation);
5184
- }
5185
- } else {
5186
- const timestamp = new Date().toISOString();
5187
- conversation = await conversationStore.create(
5188
- cronOwnerId,
5189
- `[cron] ${jobName} ${timestamp}`,
5190
- );
5191
- }
5697
+ const timestamp = new Date().toISOString();
5698
+ const conversation = await conversationStore.create(
5699
+ cronOwnerId,
5700
+ `[cron] ${jobName} ${timestamp}`,
5701
+ );
5702
+ const historyMessages: Message[] = [];
5192
5703
 
5193
5704
  const convId = conversation.conversationId;
5194
5705
  activeConversationRuns.set(convId, {
@@ -5298,20 +5809,20 @@ export const createRequestHandler = async (options?: {
5298
5809
  : undefined;
5299
5810
  const messages: Message[] = [
5300
5811
  ...historyMessages,
5301
- ...(continueConversationId
5302
- ? []
5303
- : [{ role: "user" as const, content: cronJob.task }]),
5812
+ { role: "user" as const, content: cronJob.task },
5304
5813
  ...(hasContent
5305
5814
  ? [{ role: "assistant" as const, content: assistantResponse, metadata: assistantMetadata }]
5306
5815
  : []),
5307
5816
  ];
5308
5817
  const freshConv = await conversationStore.get(convId);
5309
5818
  if (freshConv) {
5819
+ // Always persist intermediate messages so clients see progress
5820
+ freshConv.messages = messages;
5310
5821
  if (runContinuationMessages) {
5311
5822
  freshConv._continuationMessages = runContinuationMessages;
5312
5823
  } else {
5313
5824
  freshConv._continuationMessages = undefined;
5314
- freshConv.messages = messages;
5825
+ freshConv._continuationCount = undefined;
5315
5826
  }
5316
5827
  if (runResult.harnessMessages) {
5317
5828
  freshConv._harnessMessages = runResult.harnessMessages;
@@ -5324,15 +5835,13 @@ export const createRequestHandler = async (options?: {
5324
5835
  }
5325
5836
 
5326
5837
  if (runResult.continuation) {
5327
- const continuationPath = `/api/cron/${encodeURIComponent(jobName)}?continue=${encodeURIComponent(convId)}&continuation=${continuationCount + 1}`;
5328
- const work = selfFetchWithRetry(continuationPath).catch(err =>
5838
+ const work = selfFetchWithRetry(`/api/internal/continue/${encodeURIComponent(convId)}`).catch(err =>
5329
5839
  console.error(`[poncho][cron] Continuation self-fetch failed:`, err instanceof Error ? err.message : err),
5330
5840
  );
5331
5841
  doWaitUntil(work);
5332
5842
  writeJson(response, 200, {
5333
5843
  conversationId: convId,
5334
5844
  status: "continued",
5335
- continuations: continuationCount + 1,
5336
5845
  duration: Date.now() - start,
5337
5846
  });
5338
5847
  return;
@@ -6689,6 +7198,52 @@ export const buildCli = (): Command => {
6689
7198
  await listTools(process.cwd());
6690
7199
  });
6691
7200
 
7201
+ const authCommand = program.command("auth").description("Manage model provider authentication");
7202
+ authCommand
7203
+ .command("login")
7204
+ .requiredOption("--provider <provider>", "provider id (currently: openai-codex)")
7205
+ .option("--device", "use device auth flow", true)
7206
+ .action(async (options: { provider: string; device: boolean }) => {
7207
+ if (options.provider !== "openai-codex") {
7208
+ throw new Error(`Unsupported provider "${options.provider}". Try --provider openai-codex.`);
7209
+ }
7210
+ await loginOpenAICodex({ device: options.device });
7211
+ });
7212
+
7213
+ authCommand
7214
+ .command("status")
7215
+ .requiredOption("--provider <provider>", "provider id (currently: openai-codex)")
7216
+ .action(async (options: { provider: string }) => {
7217
+ if (options.provider !== "openai-codex") {
7218
+ throw new Error(`Unsupported provider "${options.provider}". Try --provider openai-codex.`);
7219
+ }
7220
+ await statusOpenAICodex();
7221
+ });
7222
+
7223
+ authCommand
7224
+ .command("logout")
7225
+ .requiredOption("--provider <provider>", "provider id (currently: openai-codex)")
7226
+ .action(async (options: { provider: string }) => {
7227
+ if (options.provider !== "openai-codex") {
7228
+ throw new Error(`Unsupported provider "${options.provider}". Try --provider openai-codex.`);
7229
+ }
7230
+ await logoutOpenAICodex();
7231
+ });
7232
+
7233
+ authCommand
7234
+ .command("export")
7235
+ .requiredOption("--provider <provider>", "provider id (currently: openai-codex)")
7236
+ .option("--format <format>", "env|json", "env")
7237
+ .action(async (options: { provider: string; format: string }) => {
7238
+ if (options.provider !== "openai-codex") {
7239
+ throw new Error(`Unsupported provider "${options.provider}". Try --provider openai-codex.`);
7240
+ }
7241
+ if (options.format !== "env" && options.format !== "json") {
7242
+ throw new Error(`Unsupported export format "${options.format}". Use env or json.`);
7243
+ }
7244
+ await exportOpenAICodex(options.format);
7245
+ });
7246
+
6692
7247
  const skillsCommand = program.command("skills").description("Manage installed skills");
6693
7248
  skillsCommand
6694
7249
  .command("add")