@blockrun/franklin 3.15.48 → 3.15.50

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.
@@ -32,3 +32,32 @@ export interface ImageGenDeps {
32
32
  export declare function createImageGenCapability(deps?: ImageGenDeps): CapabilityHandler;
33
33
  /** Back-compat static capability for callers that don't want the Content bridge. */
34
34
  export declare const imageGenCapability: CapabilityHandler;
35
+ export interface ImagePollBody {
36
+ data?: {
37
+ b64_json?: string;
38
+ url?: string;
39
+ revised_prompt?: string;
40
+ }[];
41
+ error?: unknown;
42
+ status?: string;
43
+ }
44
+ export type ImagePollOutcome = {
45
+ kind: 'completed';
46
+ body: ImagePollBody;
47
+ } | {
48
+ kind: 'failed';
49
+ error?: unknown;
50
+ } | {
51
+ kind: 'timed_out';
52
+ } | {
53
+ kind: 'poll_http_error';
54
+ status: number;
55
+ bodyPreview: string;
56
+ };
57
+ export interface PollImageJobOptions {
58
+ /** Total wall-clock ceiling. Defaults to 5 min (matches videogen scale). */
59
+ maxWaitMs?: number;
60
+ /** Sleep between polls. Defaults to 3 s. */
61
+ intervalMs?: number;
62
+ }
63
+ export declare function pollImageJob(pollEndpoint: string, headers: Record<string, string>, signal: AbortSignal, options?: PollImageJobOptions): Promise<ImagePollOutcome>;
@@ -264,11 +264,9 @@ function buildExecute(deps) {
264
264
  // when the upstream image model takes longer than the inline budget
265
265
  // (gpt-image-1/-2 routinely exceed 30s). Verified 2026-05-04 from
266
266
  // Cloud Run logs — five back-to-back ImageGen calls that the agent
267
- // saw as "No image data returned from API" had all returned 202 +
268
- // queued status; payment was settled but the result was still
269
- // generating async. Without polling here, the agent burns retries
270
- // (each charged) and the image silently completes elsewhere with
271
- // no way for Franklin to retrieve it. Mirror videogen.ts's
267
+ // saw as "No image data returned from API" had all returned 202;
268
+ // 4 of 5 actually completed in GCS within 41–56s and would have
269
+ // been retrievable if Franklin had polled. Mirror videogen.ts's
272
270
  // pollUntilReady contract: same x-payment header on each poll.
273
271
  if (response.status === 202 && result.poll_url) {
274
272
  const origin = new URL(apiUrl).origin;
@@ -280,44 +278,24 @@ function buildExecute(deps) {
280
278
  // generation routinely completes within 1–3 min once queued; the
281
279
  // 5 min ceiling matches videogen's POLL_MAX_WAIT_MS scale.
282
280
  clearTimeout(timeout);
283
- const pollDeadline = Date.now() + 5 * 60 * 1000;
284
- let polled = null;
285
- while (Date.now() < pollDeadline) {
286
- if (controller.signal.aborted)
287
- throw new Error('aborted');
288
- await new Promise(r => setTimeout(r, 3_000));
289
- const pollResp = await fetch(pollEndpoint, {
290
- method: 'GET',
291
- headers: pollHeaders,
292
- signal: controller.signal,
293
- });
294
- if (pollResp.status === 202)
295
- continue; // still queued
296
- if (pollResp.status === 429 || pollResp.status >= 500)
297
- continue; // transient
298
- if (pollResp.ok) {
299
- polled = await pollResp.json().catch(() => null);
300
- if (polled && (polled.status === 'completed' || polled.data?.[0]))
301
- break;
302
- if (polled && polled.status === 'failed') {
303
- return {
304
- output: `Image generation failed upstream: ${JSON.stringify(polled.error ?? polled).slice(0, 240)}`,
305
- isError: true,
306
- };
307
- }
308
- continue;
309
- }
310
- const text = await pollResp.text().catch(() => '');
311
- return { output: `Image poll failed (${pollResp.status}): ${text.slice(0, 200)}`, isError: true };
281
+ const outcome = await pollImageJob(pollEndpoint, pollHeaders, controller.signal);
282
+ if (outcome.kind === 'failed') {
283
+ return {
284
+ output: `Image generation failed upstream: ${JSON.stringify(outcome.error ?? '').slice(0, 240)}`,
285
+ isError: true,
286
+ };
312
287
  }
313
- if (!polled || !polled.data?.[0]) {
288
+ if (outcome.kind === 'poll_http_error') {
289
+ return { output: `Image poll failed (${outcome.status}): ${outcome.bodyPreview}`, isError: true };
290
+ }
291
+ if (outcome.kind === 'timed_out') {
314
292
  return {
315
293
  output: `Image generation queued but did not complete within 5 minutes. Payment was settled when the gateway accepted the job (HTTP 202). ` +
316
294
  `If this keeps happening, the upstream image model is overloaded — try a smaller / faster model or retry later.`,
317
295
  isError: true,
318
296
  };
319
297
  }
320
- result = polled;
298
+ result = outcome.body;
321
299
  }
322
300
  const imageData = result.data?.[0];
323
301
  if (!imageData) {
@@ -533,3 +511,48 @@ export function createImageGenCapability(deps = {}) {
533
511
  }
534
512
  /** Back-compat static capability for callers that don't want the Content bridge. */
535
513
  export const imageGenCapability = createImageGenCapability();
514
+ export async function pollImageJob(pollEndpoint, headers, signal, options = {}) {
515
+ const maxWaitMs = options.maxWaitMs ?? 5 * 60 * 1000;
516
+ const intervalMs = options.intervalMs ?? 3_000;
517
+ const deadline = Date.now() + maxWaitMs;
518
+ while (Date.now() < deadline) {
519
+ if (signal.aborted)
520
+ throw new Error('aborted');
521
+ await sleep(intervalMs, signal);
522
+ const resp = await fetch(pollEndpoint, { method: 'GET', headers, signal });
523
+ if (resp.status === 202)
524
+ continue; // still queued
525
+ if (resp.status === 429 || resp.status >= 500)
526
+ continue; // transient
527
+ if (resp.ok) {
528
+ const body = (await resp.json().catch(() => null));
529
+ if (!body)
530
+ continue;
531
+ if (body.status === 'failed')
532
+ return { kind: 'failed', error: body.error };
533
+ if (body.status === 'completed' || (body.data && body.data[0])) {
534
+ return { kind: 'completed', body };
535
+ }
536
+ // Non-terminal but ok shape (e.g. status: 'in_progress') — wait.
537
+ continue;
538
+ }
539
+ const text = await resp.text().catch(() => '');
540
+ return { kind: 'poll_http_error', status: resp.status, bodyPreview: text.slice(0, 200) };
541
+ }
542
+ return { kind: 'timed_out' };
543
+ }
544
+ function sleep(ms, signal) {
545
+ return new Promise((resolve, reject) => {
546
+ if (signal.aborted)
547
+ return reject(new Error('aborted'));
548
+ const t = setTimeout(() => {
549
+ signal.removeEventListener('abort', onAbort);
550
+ resolve();
551
+ }, ms);
552
+ const onAbort = () => {
553
+ clearTimeout(t);
554
+ reject(new Error('aborted'));
555
+ };
556
+ signal.addEventListener('abort', onAbort, { once: true });
557
+ });
558
+ }
package/dist/ui/app.js CHANGED
@@ -14,6 +14,7 @@ import { resolveModel, PICKER_CATEGORIES, PICKER_MODELS_FLAT, } from './model-pi
14
14
  import { estimateCost } from '../pricing.js';
15
15
  import { formatTokens, shortModelName } from '../stats/format.js';
16
16
  import { mouse, forceDisableMouseTracking } from './mouse.js';
17
+ import { resolveAskUserAnswer } from './ask-user-answer.js';
17
18
  // ─── Full-width input box ──────────────────────────────────────────────────
18
19
  const DISABLE_AUTO_WRAP = '\x1b[?7l';
19
20
  const ENABLE_AUTO_WRAP = '\x1b[?7h';
@@ -827,7 +828,12 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
827
828
  ? _jsxs(Text, { color: r.ctxPct >= 80 ? 'red' : r.ctxPct >= 50 ? 'yellow' : undefined, dimColor: r.ctxPct < 50, children: [" \u00B7 ctx ", r.ctxPct, "%"] })
828
829
  : ''] }) }))] }, r.key));
829
830
  } }), permissionRequest && (_jsxs(Box, { flexDirection: "column", marginTop: 1, marginLeft: 2, children: [_jsx(Text, { color: "red", bold: true, children: "\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501 \u26A0 ACTION REQUIRED \u26A0 \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501" }), _jsx(Text, { color: "yellow", children: "\u256D\u2500 Permission required \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" }), _jsxs(Text, { color: "yellow", children: ["\u2502 ", _jsx(Text, { bold: true, children: permissionRequest.toolName })] }), permissionRequest.description.split('\n').map((line, i) => (_jsxs(Text, { dimColor: true, children: ["\u2502 ", line] }, i))), _jsx(Text, { color: "yellow", children: "\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" }), _jsx(Box, { marginLeft: 2, children: _jsxs(Text, { children: [_jsx(Text, { bold: true, color: "green", children: "[y]" }), _jsx(Text, { dimColor: true, children: " yes " }), _jsx(Text, { bold: true, color: "cyan", children: "[a]" }), _jsx(Text, { dimColor: true, children: " always " }), _jsx(Text, { bold: true, color: "red", children: "[n]" }), _jsx(Text, { dimColor: true, children: " no" })] }) })] })), askUserRequest && (_jsxs(Box, { flexDirection: "column", marginTop: 1, marginLeft: 2, children: [_jsx(Text, { color: "magenta", bold: true, children: "\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501 \u26A0 ANSWER REQUIRED \u26A0 \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501" }), _jsx(Text, { color: "cyan", children: "\u256D\u2500 Question \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" }), _jsxs(Text, { color: "cyan", children: ["\u2502 ", _jsx(Text, { bold: true, children: askUserRequest.question })] }), askUserRequest.options && askUserRequest.options.length > 0 && (askUserRequest.options.map((opt, i) => (_jsxs(Text, { dimColor: true, children: ["\u2502 ", i + 1, ". ", opt] }, i)))), _jsx(Text, { color: "cyan", children: "\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" }), _jsxs(Box, { marginLeft: 2, children: [_jsx(Text, { bold: true, children: "answer> " }), _jsx(TextInput, { value: askUserInput, onChange: setAskUserInput, onSubmit: (val) => {
830
- const answer = val.trim() || '(no response)';
831
+ // resolveAskUserAnswer translates "1" / "2" / ... into the
832
+ // matching label string when the dialog showed a numbered
833
+ // option list. Without it, every onAskUser caller's
834
+ // exact-label match fails for digit answers and silently
835
+ // falls through to the default branch (typically cancel).
836
+ const answer = resolveAskUserAnswer(val, askUserRequest.options);
831
837
  const r = askUserRequest.resolve;
832
838
  setAskUserRequest(null);
833
839
  setAskUserInput('');
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Resolve a user-typed AskUser answer against the option list.
3
+ *
4
+ * The TUI renders option labels as a numbered list ("1. X", "2. Y", …),
5
+ * so users naturally type the digit. Every tool-side onAskUser caller
6
+ * (videogen.ts:113, modal.ts:371, jupiter.ts:368, zerox-base.ts:453,
7
+ * zerox-gasless.ts:446) does an exact-string match against the full
8
+ * label, so a bare "1" silently falls through to the caller's default
9
+ * branch — which is typically "cancel".
10
+ *
11
+ * Verified 2026-05-04 in a live session: user typed "1" twice in a
12
+ * VideoGen flow, both invocations returned "Video generation cancelled
13
+ * (No USDC was spent)" even though the wallet had $94.72 and the
14
+ * Content budget had $2.00 untouched.
15
+ *
16
+ * Translation rules:
17
+ * - "" → "(no response)" (preserve the existing empty-input fallback)
18
+ * - "<digit>" with options.length > 0 and 1 ≤ digit ≤ options.length
19
+ * → the matching label string
20
+ * - anything else → the trimmed input verbatim (callers that match
21
+ * against label can still get a literal answer when the user types
22
+ * it out, and free-form text questions still work the same way)
23
+ */
24
+ export declare function resolveAskUserAnswer(raw: string, options: readonly string[] | undefined): string;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Resolve a user-typed AskUser answer against the option list.
3
+ *
4
+ * The TUI renders option labels as a numbered list ("1. X", "2. Y", …),
5
+ * so users naturally type the digit. Every tool-side onAskUser caller
6
+ * (videogen.ts:113, modal.ts:371, jupiter.ts:368, zerox-base.ts:453,
7
+ * zerox-gasless.ts:446) does an exact-string match against the full
8
+ * label, so a bare "1" silently falls through to the caller's default
9
+ * branch — which is typically "cancel".
10
+ *
11
+ * Verified 2026-05-04 in a live session: user typed "1" twice in a
12
+ * VideoGen flow, both invocations returned "Video generation cancelled
13
+ * (No USDC was spent)" even though the wallet had $94.72 and the
14
+ * Content budget had $2.00 untouched.
15
+ *
16
+ * Translation rules:
17
+ * - "" → "(no response)" (preserve the existing empty-input fallback)
18
+ * - "<digit>" with options.length > 0 and 1 ≤ digit ≤ options.length
19
+ * → the matching label string
20
+ * - anything else → the trimmed input verbatim (callers that match
21
+ * against label can still get a literal answer when the user types
22
+ * it out, and free-form text questions still work the same way)
23
+ */
24
+ export function resolveAskUserAnswer(raw, options) {
25
+ const trimmed = raw.trim();
26
+ if (!trimmed)
27
+ return '(no response)';
28
+ if (options && options.length > 0 && /^\d+$/.test(trimmed)) {
29
+ const idx = parseInt(trimmed, 10) - 1;
30
+ if (idx >= 0 && idx < options.length)
31
+ return options[idx];
32
+ }
33
+ return trimmed;
34
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockrun/franklin",
3
- "version": "3.15.48",
3
+ "version": "3.15.50",
4
4
  "description": "Franklin — The AI agent with a wallet. Spends USDC autonomously to get real work done. Pay per action, no subscriptions.",
5
5
  "type": "module",
6
6
  "exports": {