@blockrun/franklin 3.15.1 → 3.15.3

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,4 +1,5 @@
1
1
  import type { CapabilityInvocation, CapabilityResult, ExecutionScope } from './types.js';
2
+ export declare const BLOCKING_POLL_LOOP_RE: RegExp;
2
3
  export declare function normalizeSearchQuery(query: string): {
3
4
  normalized: string;
4
5
  tokens: string[];
@@ -38,6 +38,13 @@ function globKey(invocation) {
38
38
  const path = normalizePath(String(invocation.input.path ?? ''));
39
39
  return `${pattern}::${path}`;
40
40
  }
41
+ // Detect a blocking poll-loop in a foreground bash command:
42
+ // any `for|while|until` loop containing a `sleep` of ≥1 second. This is
43
+ // the canonical antipattern that makes the agent feel frozen — see
44
+ // beforeBash() for the full rationale and the recommended alternatives.
45
+ // Use [\s\S] for cross-line match so we catch multi-line scripts; require
46
+ // `sleep [1-9]` so trivial `sleep 0` / `sleep 0.1` micro-pauses don't trip.
47
+ export const BLOCKING_POLL_LOOP_RE = /\b(?:for|while|until)\b[\s\S]*?\bsleep\s+[1-9]/;
41
48
  const WRITE_KEYWORDS = (() => {
42
49
  const words = [
43
50
  'rm', 'mv', 'cp', 'mkdir', 'touch', 'chmod', 'chown', 'ln',
@@ -212,6 +219,30 @@ export class SessionToolGuard {
212
219
  const cmd = String(invocation.input.command ?? '').trim();
213
220
  if (!cmd)
214
221
  return null;
222
+ // Reject blocking poll-loops in foreground bash. A single bash call with
223
+ // `sleep N` inside a for/while/until loop blocks the agent for the full
224
+ // duration — the UI repeats the same status line and the user almost
225
+ // always cancels before it finishes. The right pattern is `Detach`
226
+ // (persistent background task) or `run_in_background: true`.
227
+ const runInBackground = Boolean(invocation.input.run_in_background);
228
+ if (!runInBackground && BLOCKING_POLL_LOOP_RE.test(cmd)) {
229
+ return {
230
+ output: 'Blocked: this Bash command runs `sleep` inside a for/while/until loop in the ' +
231
+ 'foreground. That blocks the agent for the full poll duration and looks frozen ' +
232
+ 'to the user — they almost always cancel before it finishes.\n\n' +
233
+ 'Use the `Detach` tool for polling-style work (waiting for an Apify run, video ' +
234
+ 'generation, deploy, build, or any external async job to complete). It returns ' +
235
+ 'a runId immediately and the polling continues persistently. Check status later ' +
236
+ 'with `franklin task wait <runId>` or `franklin task tail <runId>` via a ' +
237
+ 'separate Bash call.\n\n' +
238
+ 'If you need the result inline, break the loop into discrete single-poll Bash ' +
239
+ 'calls — poll once, reason about the status, then decide whether to poll again. ' +
240
+ 'Or, if the upstream API has a sync variant (e.g. Apify\'s ' +
241
+ '`run-sync-get-dataset-items`), use that with a `timeout` of 300000–600000 ms ' +
242
+ 'instead of orchestrating async + poll yourself.',
243
+ isError: true,
244
+ };
245
+ }
215
246
  // Only dedup deterministic read-only commands. Skip anything writing/network/long-running.
216
247
  if (WRITE_KEYWORDS.test(cmd))
217
248
  return null;
@@ -511,6 +511,10 @@ IMPORTANT: Avoid using this tool to run \`find\`, \`grep\`, \`cat\`, \`head\`, \
511
511
  - Avoid unnecessary \`sleep\` commands:
512
512
  - Do not sleep between commands that can run immediately — just run them.
513
513
  - Do not retry failing commands in a sleep loop — diagnose the root cause.
514
+ - Do NOT write \`sleep\` inside a for/while/until loop in a single foreground Bash call to poll an external async job. That blocks the agent for the whole poll duration and looks frozen to the user; they will cancel before it finishes. Pick one:
515
+ 1. Use the \`Detach\` tool for polling-style work (waiting for an Apify run, video generation, deploy, or build to complete). It returns a runId immediately and the polling runs persistently; check status later with \`franklin task wait/tail <runId>\`.
516
+ 2. Use the upstream sync endpoint when one exists (e.g. Apify's \`run-sync-get-dataset-items\`) with an explicit \`timeout\` up to 600000ms — usually simpler than orchestrating async + poll yourself.
517
+ 3. Break the poll into discrete single-call polls — one poll per Bash call, reason about the status between calls, decide whether to poll again. The user can then see progress and course-correct.
514
518
 
515
519
  Output is capped at 512KB capture / 32KB return.`,
516
520
  input_schema: {
@@ -32,13 +32,16 @@ export const detachCapability = {
32
32
  description: "Run a Bash command as a detached background job. Returns immediately " +
33
33
  "with a runId. The command continues even if Franklin exits or the user " +
34
34
  "closes their terminal. Use this for any iteration over more than ~20 " +
35
- "items, large data fetches, paginated API loops, or anything you'd " +
36
- "otherwise loop on turn-by-turn (which would burn turns and trip " +
37
- "timeouts). The agent's job is to design and orchestrate, not to be " +
38
- "the for-loop. Pair with a script that writes a checkpoint file so " +
39
- "progress survives restarts. Tail logs with `franklin task tail " +
40
- "<runId> --follow` and check completion with `franklin task wait " +
41
- "<runId>`.",
35
+ "items, large data fetches, paginated API loops, polling external async " +
36
+ "jobs (waiting for an Apify run / video generation / deploy / build to " +
37
+ "complete), or anything you'd otherwise loop on turn-by-turn (which " +
38
+ "would burn turns and trip timeouts). The agent's job is to design and " +
39
+ "orchestrate, not to be the for-loop. Pair with a script that writes a " +
40
+ "checkpoint file so progress survives restarts. Tail logs with " +
41
+ "`franklin task tail <runId> --follow` and check completion with " +
42
+ "`franklin task wait <runId>`. ALWAYS prefer Detach over a single " +
43
+ "foreground Bash call with `sleep` inside a for/while/until loop — that " +
44
+ "antipattern blocks the agent for the full duration and looks frozen.",
42
45
  input_schema: {
43
46
  type: 'object',
44
47
  properties: {
package/dist/ui/app.js CHANGED
@@ -99,6 +99,11 @@ function formatAgentErrorForDisplay(error) {
99
99
  }
100
100
  function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain, startWithPicker, onSubmit, onModelChange, onAbort, onExit, }) {
101
101
  const { exit } = useApp();
102
+ // Track terminal rows so we can cap the dynamic-region height. Ink wipes the
103
+ // terminal scrollback (via ansiEscapes.clearTerminal → \x1b[3J) whenever the
104
+ // dynamic output exceeds rows, so any tall live region (streaming text,
105
+ // model picker) must be windowed to preserve "scroll to the start" history.
106
+ const { rows: termRows } = useTerminalSize();
102
107
  const [input, setInput] = useState('');
103
108
  const [streamText, setStreamText] = useState('');
104
109
  const [thinking, setThinking] = useState(false);
@@ -761,7 +766,7 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
761
766
  setAskUserRequest(null);
762
767
  setAskUserInput('');
763
768
  r(answer);
764
- }, focus: true })] })] })), expandableTool && (() => {
769
+ }, focus: true })] })] })), expandableTool && !permissionRequest && !askUserRequest && (() => {
765
770
  const tool = expandableTool;
766
771
  const elapsedFmt = tool.elapsed >= 1000
767
772
  ? `${(tool.elapsed / 1000).toFixed(1)}s`
@@ -776,18 +781,46 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
776
781
  const lines = thinkingText.split('\n').filter(Boolean).slice(-3);
777
782
  return (_jsx(Box, { flexDirection: "column", marginLeft: 2, children: lines.map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ['⎿ ', line.slice(0, 120)] }, i))) }));
778
783
  })()] })), waiting && !thinking && tools.size === 0 && (_jsx(Box, { marginLeft: 2, children: _jsxs(Text, { color: "yellow", children: [_jsx(Spinner, { type: "dots" }), ' ', _jsxs(Text, { dimColor: true, children: [shortModelName(currentModel), completedTools.length > 0 ? ` · step ${completedTools.length + 1}` : ''] })] }) })), streamText && (() => {
779
- const { rendered, partial } = renderMarkdownStreaming(streamText);
780
- return (_jsx(Box, { marginTop: 0, marginBottom: 0, marginLeft: 2, children: _jsxs(Text, { wrap: "wrap", children: [rendered, rendered && partial ? '\n' : '', partial] }) }));
781
- })(), responsePreview && !streamText && (_jsx(Box, { flexDirection: "column", marginBottom: 0, marginLeft: 2, children: _jsx(Text, { wrap: "wrap", children: renderMarkdown(responsePreview) }) })), inPicker && (() => {
782
- let flatIdx = 0;
783
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { marginLeft: 2, children: [_jsx(Text, { bold: true, children: "Select a model " }), _jsx(Text, { dimColor: true, children: "(\u2191\u2193 navigate, Enter select, Esc cancel)" })] }), PICKER_CATEGORIES.map((cat) => (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Box, { marginLeft: 2, children: _jsxs(Text, { dimColor: true, children: ["\u2500\u2500 ", cat.category, " \u2500\u2500"] }) }), cat.models.map((m) => {
784
- const myIdx = flatIdx++;
785
- const isSelected = myIdx === pickerIdx;
786
- const isCurrent = m.id === currentModel;
787
- const isHighlight = m.highlight === true;
788
- return (_jsxs(Box, { marginLeft: 2, children: [_jsxs(Text, { inverse: isSelected, color: isSelected ? 'cyan' : isHighlight ? 'yellow' : undefined, bold: isSelected || isHighlight, children: [' ', m.label.padEnd(26), ' '] }), _jsxs(Text, { dimColor: true, children: [" ", m.shortcut.padEnd(14)] }), _jsx(Text, { color: m.price === 'FREE' ? 'green' : isHighlight ? 'yellow' : undefined, dimColor: !isHighlight && m.price !== 'FREE', children: m.price }), isCurrent && _jsx(Text, { color: "green", children: " \u2190" })] }, m.id));
789
- })] }, cat.category))), _jsx(Box, { marginTop: 1, marginLeft: 2, children: _jsx(Text, { dimColor: true, children: "Your conversation stays above \u2014 picking a model keeps all history intact." }) })] }));
790
- })(), !inPicker && (_jsx(InputBox, { input: (permissionRequest || askUserRequest) ? '' : input, setInput: (permissionRequest || askUserRequest) ? () => { } : setInput, onSubmit: (permissionRequest || askUserRequest) ? () => { } : handleSubmit, model: currentModel, balance: liveBalance, chain: chain, walletTail: walletAddress && walletAddress.length >= 4 && !walletAddress.startsWith('not set') ? walletAddress.slice(-4) : undefined, sessionCost: totalCost, queued: queuedInputs[0] || undefined, queuedCount: queuedInputs.length, focused: !permissionRequest && !askUserRequest, busy: !askUserRequest && (waiting || thinking || tools.size > 0), contextPct: contextPct, vimMode: vimEnabled, onVimModeChange: setCurrentVimMode }))] }));
784
+ const maxLines = Math.max(8, termRows - 12);
785
+ const lines = streamText.split('\n');
786
+ const truncated = lines.length > maxLines;
787
+ const visible = truncated ? lines.slice(-maxLines).join('\n') : streamText;
788
+ const { rendered, partial } = renderMarkdownStreaming(visible);
789
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 0, marginBottom: 0, marginLeft: 2, children: [truncated && (_jsxs(Text, { dimColor: true, children: ["\u2191 ", lines.length - maxLines, " earlier line", lines.length - maxLines === 1 ? '' : 's', " \u2014 full response will appear in scrollback when this turn finishes"] })), _jsxs(Text, { wrap: "wrap", children: [rendered, rendered && partial ? '\n' : '', partial] })] }));
790
+ })(), responsePreview && !streamText && !permissionRequest && !askUserRequest && (_jsx(Box, { flexDirection: "column", marginBottom: 0, marginLeft: 2, children: _jsx(Text, { wrap: "wrap", children: renderMarkdown(responsePreview) }) })), inPicker && (() => {
791
+ const totalModels = PICKER_MODELS_FLAT.length;
792
+ const maxModels = Math.max(6, termRows - 12);
793
+ let start = Math.max(0, pickerIdx - Math.floor(maxModels / 2));
794
+ let end = Math.min(totalModels, start + maxModels);
795
+ // Expand window backward if we hit the bottom of the list, so we
796
+ // always fill `maxModels` rows when the list is long enough.
797
+ if (end - start < maxModels)
798
+ start = Math.max(0, end - maxModels);
799
+ const hiddenAbove = start;
800
+ const hiddenBelow = totalModels - end;
801
+ // Pre-compute each category's base offset into the flat model list so
802
+ // we can map (cat, localIdx) → globalIdx in one pass without re-walking.
803
+ let cursor = 0;
804
+ const catBases = PICKER_CATEGORIES.map((cat) => {
805
+ const base = cursor;
806
+ cursor += cat.models.length;
807
+ return base;
808
+ });
809
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { marginLeft: 2, children: [_jsx(Text, { bold: true, children: "Select a model " }), _jsx(Text, { dimColor: true, children: "(\u2191\u2193 navigate, Enter select, Esc cancel)" })] }), hiddenAbove > 0 && (_jsx(Box, { marginLeft: 2, marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["\u2191 ", hiddenAbove, " more above"] }) })), PICKER_CATEGORIES.map((cat, catIdx) => {
810
+ const base = catBases[catIdx];
811
+ const visible = cat.models
812
+ .map((m, localIdx) => ({ m, globalIdx: base + localIdx }))
813
+ .filter(({ globalIdx }) => globalIdx >= start && globalIdx < end);
814
+ if (visible.length === 0)
815
+ return null;
816
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Box, { marginLeft: 2, children: _jsxs(Text, { dimColor: true, children: ["\u2500\u2500 ", cat.category, " \u2500\u2500"] }) }), visible.map(({ m, globalIdx }) => {
817
+ const isSelected = globalIdx === pickerIdx;
818
+ const isCurrent = m.id === currentModel;
819
+ const isHighlight = m.highlight === true;
820
+ return (_jsxs(Box, { marginLeft: 2, children: [_jsxs(Text, { inverse: isSelected, color: isSelected ? 'cyan' : isHighlight ? 'yellow' : undefined, bold: isSelected || isHighlight, children: [' ', m.label.padEnd(26), ' '] }), _jsxs(Text, { dimColor: true, children: [" ", m.shortcut.padEnd(14)] }), _jsx(Text, { color: m.price === 'FREE' ? 'green' : isHighlight ? 'yellow' : undefined, dimColor: !isHighlight && m.price !== 'FREE', children: m.price }), isCurrent && _jsx(Text, { color: "green", children: " \u2190" })] }, m.id));
821
+ })] }, cat.category));
822
+ }), hiddenBelow > 0 && (_jsx(Box, { marginLeft: 2, marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["\u2193 ", hiddenBelow, " more below"] }) })), _jsx(Box, { marginTop: 1, marginLeft: 2, children: _jsx(Text, { dimColor: true, children: "Your conversation stays above \u2014 picking a model keeps all history intact." }) })] }));
823
+ })(), !inPicker && !permissionRequest && !askUserRequest && (_jsx(InputBox, { input: input, setInput: setInput, onSubmit: handleSubmit, model: currentModel, balance: liveBalance, chain: chain, walletTail: walletAddress && walletAddress.length >= 4 && !walletAddress.startsWith('not set') ? walletAddress.slice(-4) : undefined, sessionCost: totalCost, queued: queuedInputs[0] || undefined, queuedCount: queuedInputs.length, focused: !permissionRequest && !askUserRequest, busy: !askUserRequest && (waiting || thinking || tools.size > 0), contextPct: contextPct, vimMode: vimEnabled, onVimModeChange: setCurrentVimMode }))] }));
791
824
  }
792
825
  export function launchInkUI(opts) {
793
826
  let resolveInput = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockrun/franklin",
3
- "version": "3.15.1",
3
+ "version": "3.15.3",
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": {