@blockrun/franklin 3.16.3 → 3.17.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.
@@ -233,7 +233,7 @@ const DIRECT_COMMANDS = {
233
233
  ` **Coding:** /commit /review /test /fix /debug /explain /search /find /refactor /scaffold\n` +
234
234
  ` **Git:** /push /pr /undo /status /diff /log /branch /stash /unstash\n` +
235
235
  ` **Analysis:** /security /lint /optimize /todo /deps /clean /migrate /doc\n` +
236
- ` **Session:** /plan /ultraplan /execute /compact /retry /sessions /resume /session-search /context /tasks\n` +
236
+ ` **Session:** /plan /ultraplan /execute /compact /retry /sessions /resume /session-search /context /tasks /history /transcript\n` +
237
237
  ` **Power:** /ultrathink [query] /ultraplan /noplan /moa [query] /dump\n` +
238
238
  ` **Info:** /model /auto /wallet /cost /tokens /learnings /brain /mcp /doctor /version /bug /help\n` +
239
239
  ` **UI:** /clear /exit\n` +
@@ -259,6 +259,53 @@ const DIRECT_COMMANDS = {
259
259
  }
260
260
  }
261
261
  output += 'Use `/delete <number>` to remove exchanges (e.g., `/delete 2` or `/delete 3-5`).\n';
262
+ output += 'Use `/transcript` for the full, un-truncated transcript.\n';
263
+ ctx.onEvent({ kind: 'text_delta', text: output });
264
+ emitDone(ctx);
265
+ },
266
+ '/transcript': (ctx) => {
267
+ // Dump the FULL, un-truncated conversation as a single fresh stdout
268
+ // block. Works around the limitation that the terminal's native
269
+ // scrollback fills up faster than we can render long Franklin sessions
270
+ // — by the time you scroll up to look for the first message, the
271
+ // older Ink output has been pushed out of the terminal's ring buffer.
272
+ // The fresh emit here becomes one contiguous block, so the user can
273
+ // scroll *that* to read everything in order.
274
+ const { history, config } = ctx;
275
+ const modelName = config.model.split('/').pop() || config.model;
276
+ const exchanges = buildExchanges(history);
277
+ if (exchanges.length === 0) {
278
+ ctx.onEvent({ kind: 'text_delta', text: 'No history in the current session yet.\n' });
279
+ emitDone(ctx);
280
+ return;
281
+ }
282
+ let output = `**Full Transcript** — ${exchanges.length} exchange${exchanges.length === 1 ? '' : 's'}, session \`${ctx.sessionId}\`\n\n`;
283
+ output += '─'.repeat(70) + '\n\n';
284
+ for (let i = 0; i < exchanges.length; i++) {
285
+ const ex = exchanges[i];
286
+ // Re-read full text from raw history (buildExchanges truncated).
287
+ const userMsg = history[ex.startIdx];
288
+ const fullUser = extractText(userMsg);
289
+ // Find first assistant text in this exchange (full, not truncated).
290
+ let fullAssistant = '';
291
+ for (let j = ex.startIdx + 1; j <= ex.endIdx; j++) {
292
+ const m = history[j];
293
+ if (m.role === 'assistant') {
294
+ const t = extractText(m);
295
+ if (t && !fullAssistant)
296
+ fullAssistant = t;
297
+ }
298
+ }
299
+ output += `❯ [${i + 1}] ${fullUser}\n\n`;
300
+ if (fullAssistant)
301
+ output += `${fullAssistant}\n`;
302
+ if (ex.toolNames.length > 0) {
303
+ output += `\n _tools: ${ex.toolNames.join(', ')}_\n`;
304
+ }
305
+ output += `\n[${modelName}]\n\n`;
306
+ output += '─'.repeat(70) + '\n\n';
307
+ }
308
+ output += `End of transcript — ${history.length} raw messages, ${exchanges.length} user/assistant exchanges.\n`;
262
309
  ctx.onEvent({ kind: 'text_delta', text: output });
263
310
  emitDone(ctx);
264
311
  },
@@ -150,7 +150,17 @@ RULES (violations will produce garbage output):
150
150
  6. End with: "Reply to any? Give me the number."
151
151
  7. Do NOT auto-post. Do NOT explain how the social system works.
152
152
 
153
- When checking notifications/mentions: Use SearchX with mode="notifications". One call, done.`;
153
+ When the user pastes a specific tweet URL (https://x.com/<user>/status/<id>): Call SearchX with the URL as the query. The tool auto-detects URL mode and reads the post directly. Do NOT search for the URL as a keyword (always returns empty), and do NOT try WebFetch on x.com.
154
+
155
+ When checking notifications/mentions: Use SearchX with mode="notifications". One call, done.
156
+
157
+ If SearchX returns empty or "no article extracted" on a URL/query you believe SHOULD have content (you can see the page in the browser, or the user confirms it exists), DO NOT give up — drop down to the BrowserX primitive and drive the browser yourself:
158
+ 1. BrowserX action="snapshot" → see what's on screen right now
159
+ 2. BrowserX action="scroll" dy=600 → trigger lazy-render / load more
160
+ 3. BrowserX action="snapshot" again → re-inspect after scroll
161
+ 4. BrowserX action="click" ref=<id> → follow a permalink (refs come from the last snapshot)
162
+ 5. BrowserX action="open" url=<other> → try a different URL (e.g. /search?q=… or a profile page)
163
+ BrowserX shares the logged-in X session with SearchX, so authentication is already handled. Use BrowserX only for read/navigation; replies still go through PostToX with explicit user confirmation.`;
154
164
  }
155
165
  function getMissingAccessSection() {
156
166
  return `# Missing Access
@@ -184,6 +184,21 @@ export function classifyAgentError(message) {
184
184
  suggestion: 'Tool schema rejected by this model. Try /model to switch to a more permissive model (e.g. sonnet), or upgrade Franklin.',
185
185
  };
186
186
  }
187
+ // Unknown / typo'd model id — gateway returns HTTP 400 with a body like
188
+ // "Unknown model: moonshot/kimi-k2". Without this branch the error falls
189
+ // through to the catch-all 'unknown' category and shows the user a bare
190
+ // "Type: Unknown" with no actionable next step.
191
+ if (includesAny(err, [
192
+ 'unknown model',
193
+ 'model not found',
194
+ 'model does not exist',
195
+ 'no such model',
196
+ ])) {
197
+ return {
198
+ category: 'schema', label: 'Schema', isTransient: false, maxRetries: 0,
199
+ suggestion: 'The gateway rejected the model id (unknown / typo). Use /model to pick a valid one, or upgrade Franklin if a fallback chain references a stale id.',
200
+ };
201
+ }
187
202
  if (includesAny(err, [
188
203
  '500',
189
204
  '502',
@@ -341,6 +341,13 @@ export function looksLikeStalledIntent(text) {
341
341
  const trimmed = text.trim();
342
342
  if (trimmed.length < 24)
343
343
  return false;
344
+ // If the final non-empty line is a short question to the user, the model is
345
+ // explicitly deferring ("Which would you prefer?", "Want me to proceed?") —
346
+ // that's a handoff, not a stall. Avoid re-invoking on another model and
347
+ // billing twice for what is in fact correct behavior.
348
+ const lastLine = trimmed.split(/\n+/).map(s => s.trim()).filter(Boolean).pop() ?? '';
349
+ if (lastLine.length > 0 && lastLine.length <= 120 && /[??]\s*$/.test(lastLine))
350
+ return false;
344
351
  // Look at the last ~400 chars only — intent-to-act lives near the end.
345
352
  const tail = trimmed.slice(-400).toLowerCase();
346
353
  // Strong "I'm about to do something" markers near the tail.
@@ -1336,7 +1343,7 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
1336
1343
  // Excludes nvidia/* and *-coder-* — they're the source population.
1337
1344
  const TOOL_USE_FALLBACK_MODELS = [
1338
1345
  'anthropic/claude-haiku-4.5',
1339
- 'moonshot/kimi-k2',
1346
+ 'moonshot/kimi-k2.6',
1340
1347
  'openai/gpt-5',
1341
1348
  'anthropic/claude-sonnet-4.6',
1342
1349
  ];
@@ -33,10 +33,10 @@ function isCommonDevCommand(cmd) {
33
33
  return COMMON_DEV_PATTERNS.some(p => p.test(trimmed));
34
34
  }
35
35
  // ─── Default Rules ─────────────────────────────────────────────────────────
36
- const READ_ONLY_TOOLS = new Set(['Read', 'Glob', 'Grep', 'WebSearch', 'Task', 'AskUser', 'ActivateTool', 'ImageGen', 'TradingSignal', 'TradingMarket', 'SearchX']);
36
+ const READ_ONLY_TOOLS = new Set(['Read', 'Glob', 'Grep', 'WebSearch', 'Task', 'AskUser', 'ActivateTool', 'ImageGen', 'TradingSignal', 'TradingMarket', 'SearchX', 'BrowserX']);
37
37
  const DESTRUCTIVE_TOOLS = new Set(['Write', 'Edit', 'Bash']);
38
38
  const DEFAULT_RULES = {
39
- allow: ['Read', 'Glob', 'Grep', 'WebSearch', 'WebFetch', 'Task', 'AskUser', 'ActivateTool', 'ImageGen', 'TradingSignal', 'TradingMarket', 'SearchX'],
39
+ allow: ['Read', 'Glob', 'Grep', 'WebSearch', 'WebFetch', 'Task', 'AskUser', 'ActivateTool', 'ImageGen', 'TradingSignal', 'TradingMarket', 'SearchX', 'BrowserX'],
40
40
  deny: [],
41
41
  ask: ['Write', 'Edit', 'Bash', 'Agent', 'PostToX'],
42
42
  };
@@ -183,6 +183,13 @@ export class SessionToolGuard {
183
183
  startTurn() {
184
184
  this.turn++;
185
185
  this.webSearchesThisTurn = 0;
186
+ // The per-tool circuit breaker exists to stop a model from burning a
187
+ // whole turn re-attacking a wall. It must NOT outlive the user turn that
188
+ // earned the failures — a fresh prompt is a fresh intent. Without this
189
+ // reset, three failed Bash calls (e.g. `franklin social login x` on a
190
+ // host without the right env) permanently disable Bash for the rest of
191
+ // the session, even on completely unrelated follow-ups.
192
+ this.toolErrorCounts.clear();
186
193
  for (const family of this.searchFamilies) {
187
194
  family.turnSearches = 0;
188
195
  }
@@ -233,6 +240,32 @@ export class SessionToolGuard {
233
240
  const cmd = String(invocation.input.command ?? '').trim();
234
241
  if (!cmd)
235
242
  return null;
243
+ // Reject interactive franklin subcommands that require the human at the
244
+ // keyboard (they spawn a non-headless Chrome and wait for the user to
245
+ // close it). If the agent runs them via Bash they block until timeout,
246
+ // burn a tool-failure strike, and contribute nothing. Tell the agent to
247
+ // ask the user to run them in a separate terminal instead.
248
+ if (/^\s*franklin\s+social\s+(login|setup)\b/.test(cmd)) {
249
+ return {
250
+ output: 'Blocked: `franklin social login` / `franklin social setup` are INTERACTIVE — ' +
251
+ 'they open a Chrome window the human must drive and close. They cannot run from ' +
252
+ 'an agent Bash call (they will hang then time out). ' +
253
+ 'Ask the user to run this in their own terminal, then continue once they say it is done.',
254
+ isError: true,
255
+ };
256
+ }
257
+ // `franklin social run` is a batch poster/replier that loops over the
258
+ // user's configured search_queries — it is not the right tool for
259
+ // "read this specific tweet" or "draft replies to one post". Steer the
260
+ // agent to SearchX (now URL-aware) instead.
261
+ if (/^\s*franklin\s+social\s+run\b/.test(cmd)) {
262
+ return {
263
+ output: 'Blocked: `franklin social run` is a batch reply loop over the user\'s configured ' +
264
+ 'queries, not a single-tweet reader. Use the SearchX tool instead — pass a tweet URL ' +
265
+ 'as the query to read one post, or use mode="search"/"notifications" for discovery.',
266
+ isError: true,
267
+ };
268
+ }
236
269
  // Reject blocking poll-loops in foreground bash. A single bash call with
237
270
  // `sleep N` inside a for/while/until loop blocks the agent for the full
238
271
  // duration — the UI repeats the same status line and the user almost
@@ -21,6 +21,64 @@ function ensureProfileDir() {
21
21
  fs.mkdirSync(SOCIAL_PROFILE_DIR, { recursive: true });
22
22
  }
23
23
  }
24
+ // Chrome leaves a few singleton lock files in the user-data-dir when running.
25
+ // If the previous Chromium process crashed (or franklin was killed with -9)
26
+ // these files survive even though no real Chrome owns the profile. The next
27
+ // launchPersistentContext sees them and refuses to start the browser. We
28
+ // detect that case (lock exists + no process), remove the locks, and retry.
29
+ const SINGLETON_LOCKS = ['SingletonLock', 'SingletonCookie', 'SingletonSocket'];
30
+ function readSingletonOwnerPid() {
31
+ // On macOS/Linux, Chrome writes the PID into SingletonLock as a symlink
32
+ // target like "hostname-12345". Parse it; if any token is a live PID, the
33
+ // profile is genuinely in use.
34
+ const lockPath = path.join(SOCIAL_PROFILE_DIR, 'SingletonLock');
35
+ try {
36
+ const target = fs.readlinkSync(lockPath);
37
+ const match = /-(\d+)$/.exec(target);
38
+ if (!match)
39
+ return null;
40
+ const pid = Number.parseInt(match[1], 10);
41
+ return Number.isFinite(pid) ? pid : null;
42
+ }
43
+ catch {
44
+ return null;
45
+ }
46
+ }
47
+ function isPidAlive(pid) {
48
+ try {
49
+ process.kill(pid, 0);
50
+ return true;
51
+ }
52
+ catch (err) {
53
+ // ESRCH = no such process. EPERM = exists but we can't signal it (still alive).
54
+ return err.code === 'EPERM';
55
+ }
56
+ }
57
+ function clearStaleSingletonLocks() {
58
+ const pid = readSingletonOwnerPid();
59
+ if (pid !== null && isPidAlive(pid))
60
+ return false; // real Chrome still using it
61
+ let removedAny = false;
62
+ for (const name of SINGLETON_LOCKS) {
63
+ const p = path.join(SOCIAL_PROFILE_DIR, name);
64
+ try {
65
+ fs.rmSync(p, { force: true });
66
+ removedAny = true;
67
+ }
68
+ catch {
69
+ // ignore — file may simply not exist
70
+ }
71
+ }
72
+ return removedAny;
73
+ }
74
+ function isProfileLockError(msg) {
75
+ const m = msg.toLowerCase();
76
+ return (m.includes('profile') && (m.includes('in use') || m.includes('locked')) ||
77
+ m.includes('singletonlock') ||
78
+ m.includes('processsingleton') ||
79
+ m.includes('user data directory is already in use') ||
80
+ m.includes('failed to create a chromedriver'));
81
+ }
24
82
  /**
25
83
  * Walk an AX tree and produce:
26
84
  * 1. A flat text dump with [depth-idx] refs (for regex-based element finding)
@@ -148,18 +206,19 @@ export class SocialBrowser {
148
206
  // Lazy import — playwright-core is ~2MB and we don't want to pay the
149
207
  // import cost on every franklin command (e.g. `franklin --version`)
150
208
  const { chromium } = await import('playwright-core');
209
+ const launchOnce = () => chromium.launchPersistentContext(SOCIAL_PROFILE_DIR, {
210
+ headless: this.opts.headless,
211
+ channel: this.opts.channel,
212
+ slowMo: this.opts.slowMo,
213
+ viewport: this.opts.viewport,
214
+ // Pretend to be a regular Chrome (not headless fingerprint)
215
+ args: [
216
+ '--disable-blink-features=AutomationControlled',
217
+ '--no-default-browser-check',
218
+ ],
219
+ });
151
220
  try {
152
- this.context = await chromium.launchPersistentContext(SOCIAL_PROFILE_DIR, {
153
- headless: this.opts.headless,
154
- channel: this.opts.channel,
155
- slowMo: this.opts.slowMo,
156
- viewport: this.opts.viewport,
157
- // Pretend to be a regular Chrome (not headless fingerprint)
158
- args: [
159
- '--disable-blink-features=AutomationControlled',
160
- '--no-default-browser-check',
161
- ],
162
- });
221
+ this.context = await launchOnce();
163
222
  }
164
223
  catch (err) {
165
224
  const msg = err.message;
@@ -168,7 +227,33 @@ export class SocialBrowser {
168
227
  `Or install manually:\n npx playwright install chromium\n\n` +
169
228
  `Original error: ${msg}`);
170
229
  }
171
- throw err;
230
+ // Stale singleton-lock from a crashed Chrome / killed franklin. If no
231
+ // live PID owns the lock, scrub it and retry once. Don't auto-clean
232
+ // when a real process still owns the profile — that would corrupt
233
+ // their running session.
234
+ if (isProfileLockError(msg) || msg.toLowerCase().includes('failed to launch')) {
235
+ const cleared = clearStaleSingletonLocks();
236
+ if (cleared) {
237
+ try {
238
+ this.context = await launchOnce();
239
+ }
240
+ catch (err2) {
241
+ throw new Error(`Chrome profile lock recovery failed. The profile dir at\n ${SOCIAL_PROFILE_DIR}\n` +
242
+ `had stale lock files; we removed them and retried, but launch still failed.\n` +
243
+ `Close any running Chrome/Chromium using this profile and try again.\n\n` +
244
+ `Original error: ${msg}\nRetry error: ${err2.message}`);
245
+ }
246
+ }
247
+ else {
248
+ throw new Error(`Chrome profile is in use at\n ${SOCIAL_PROFILE_DIR}\n` +
249
+ `Another franklin instance (or a Chrome with that user-data-dir) is running.\n` +
250
+ `Close it and retry, or run: pkill -f social-chrome-profile\n\n` +
251
+ `Original error: ${msg}`);
252
+ }
253
+ }
254
+ else {
255
+ throw err;
256
+ }
172
257
  }
173
258
  // Reuse existing tab if any, else open new
174
259
  const existing = this.context.pages();
@@ -0,0 +1,17 @@
1
+ /**
2
+ * BrowserX capability — low-level primitives over Franklin's social Chrome
3
+ * profile. Use this when SearchX's pattern matcher is too rigid and the
4
+ * agent needs to drive the browser iteratively: open arbitrary URLs, take
5
+ * fresh snapshots, scroll to load more, screenshot the viewport.
6
+ *
7
+ * Shares the same persistent profile as SearchX/PostToX (~/.blockrun/
8
+ * social-chrome-profile) so the X login session is reused for free.
9
+ *
10
+ * Intentionally OMITS post-side actions (type/press/click on form fields).
11
+ * Replying still goes through PostToX, which has its own confirmation flow.
12
+ * `click` IS exposed because clicking is how you navigate on an SPA — the
13
+ * model is instructed to only click navigation elements (tweet permalinks,
14
+ * profile links, "Show more"), never reply/like/follow buttons.
15
+ */
16
+ import type { CapabilityHandler } from '../agent/types.js';
17
+ export declare const browserXCapability: CapabilityHandler;
@@ -0,0 +1,156 @@
1
+ /**
2
+ * BrowserX capability — low-level primitives over Franklin's social Chrome
3
+ * profile. Use this when SearchX's pattern matcher is too rigid and the
4
+ * agent needs to drive the browser iteratively: open arbitrary URLs, take
5
+ * fresh snapshots, scroll to load more, screenshot the viewport.
6
+ *
7
+ * Shares the same persistent profile as SearchX/PostToX (~/.blockrun/
8
+ * social-chrome-profile) so the X login session is reused for free.
9
+ *
10
+ * Intentionally OMITS post-side actions (type/press/click on form fields).
11
+ * Replying still goes through PostToX, which has its own confirmation flow.
12
+ * `click` IS exposed because clicking is how you navigate on an SPA — the
13
+ * model is instructed to only click navigation elements (tweet permalinks,
14
+ * profile links, "Show more"), never reply/like/follow buttons.
15
+ */
16
+ import path from 'node:path';
17
+ import os from 'node:os';
18
+ import { browserPool } from '../social/browser-pool.js';
19
+ const MAX_WAIT_MS = 15_000;
20
+ function summariseTree(tree, max = 8000) {
21
+ if (tree.length <= max)
22
+ return tree;
23
+ return tree.slice(0, max) + `\n\n[…truncated; tree was ${tree.length} chars total]`;
24
+ }
25
+ async function execute(input, _ctx) {
26
+ const { action, url, ref, dy, ms, path: outPath } = input;
27
+ if (!action) {
28
+ return { output: 'Error: action is required (open|snapshot|click|scroll|screenshot|getUrl|wait)', isError: true };
29
+ }
30
+ let browser;
31
+ try {
32
+ browser = await browserPool.getBrowser();
33
+ switch (action) {
34
+ case 'open': {
35
+ if (!url)
36
+ return { output: 'Error: open requires url', isError: true };
37
+ try {
38
+ await browser.open(url);
39
+ }
40
+ catch (err) {
41
+ const msg = err instanceof Error ? err.message : String(err);
42
+ return { output: `BrowserX open(${url}) failed: ${msg.slice(0, 200)}`, isError: true };
43
+ }
44
+ return { output: `Opened ${url}. Call action="snapshot" next to inspect the page.` };
45
+ }
46
+ case 'snapshot': {
47
+ try {
48
+ const tree = await browser.snapshot();
49
+ const out = `Page snapshot (${tree.length} chars):\n\n${summariseTree(tree)}\n\n` +
50
+ `Refs are valid until the next snapshot. Use action="click" with a ref to navigate.`;
51
+ return { output: out };
52
+ }
53
+ catch (err) {
54
+ const msg = err instanceof Error ? err.message : String(err);
55
+ return { output: `BrowserX snapshot failed: ${msg.slice(0, 200)}`, isError: true };
56
+ }
57
+ }
58
+ case 'click': {
59
+ if (!ref)
60
+ return { output: 'Error: click requires ref (from last snapshot)', isError: true };
61
+ try {
62
+ await browser.click(ref);
63
+ // Give the navigation a moment to start, then return — model can
64
+ // call snapshot next to see the result.
65
+ await browser.waitForTimeout(1500);
66
+ const newUrl = await browser.getUrl();
67
+ return { output: `Clicked ref [${ref}]. Current URL: ${newUrl}. Call action="snapshot" to see the result.` };
68
+ }
69
+ catch (err) {
70
+ const msg = err instanceof Error ? err.message : String(err);
71
+ return { output: `BrowserX click(${ref}) failed: ${msg.slice(0, 200)}`, isError: true };
72
+ }
73
+ }
74
+ case 'scroll': {
75
+ const delta = Number.isFinite(dy) ? Math.max(-2000, Math.min(2000, Number(dy))) : 600;
76
+ try {
77
+ await browser.scroll(640, 450, 0, delta);
78
+ await browser.waitForTimeout(800);
79
+ return { output: `Scrolled ${delta > 0 ? 'down' : 'up'} ${Math.abs(delta)}px. Call action="snapshot" to see new content.` };
80
+ }
81
+ catch (err) {
82
+ const msg = err instanceof Error ? err.message : String(err);
83
+ return { output: `BrowserX scroll failed: ${msg.slice(0, 200)}`, isError: true };
84
+ }
85
+ }
86
+ case 'screenshot': {
87
+ const finalPath = outPath
88
+ ? outPath
89
+ : path.join(os.homedir(), '.blockrun', 'screenshots', `browsex-${Date.now()}.png`);
90
+ try {
91
+ const fs = await import('node:fs');
92
+ fs.mkdirSync(path.dirname(finalPath), { recursive: true });
93
+ await browser.screenshot(finalPath);
94
+ return { output: `Screenshot saved to ${finalPath}. Use the Read tool to view it.` };
95
+ }
96
+ catch (err) {
97
+ const msg = err instanceof Error ? err.message : String(err);
98
+ return { output: `BrowserX screenshot failed: ${msg.slice(0, 200)}`, isError: true };
99
+ }
100
+ }
101
+ case 'getUrl': {
102
+ try {
103
+ const u = await browser.getUrl();
104
+ return { output: `Current URL: ${u}` };
105
+ }
106
+ catch (err) {
107
+ const msg = err instanceof Error ? err.message : String(err);
108
+ return { output: `BrowserX getUrl failed: ${msg.slice(0, 200)}`, isError: true };
109
+ }
110
+ }
111
+ case 'wait': {
112
+ const waitMs = Number.isFinite(ms) ? Math.min(MAX_WAIT_MS, Math.max(0, Number(ms))) : 2000;
113
+ await browser.waitForTimeout(waitMs);
114
+ return { output: `Waited ${waitMs}ms.` };
115
+ }
116
+ default:
117
+ return { output: `Error: unknown action "${action}" (use open|snapshot|click|scroll|screenshot|getUrl|wait)`, isError: true };
118
+ }
119
+ }
120
+ catch (err) {
121
+ const msg = err instanceof Error ? err.message : String(err);
122
+ return { output: `BrowserX error: ${msg}`, isError: true };
123
+ }
124
+ finally {
125
+ browserPool.releaseBrowser();
126
+ }
127
+ }
128
+ export const browserXCapability = {
129
+ spec: {
130
+ name: 'BrowserX',
131
+ description: 'Drive Franklin\'s social Chrome profile (logged-in to X) iteratively. ' +
132
+ 'Use when SearchX returns empty or the page needs scrolling/clicking to surface content. ' +
133
+ 'Actions: open(url), snapshot(), click(ref), scroll(dy), screenshot(), getUrl(), wait(ms). ' +
134
+ 'Snapshot returns an accessibility tree with [depth-idx] refs you pass to click. ' +
135
+ 'Refs reset on every snapshot. SAFE: do NOT use this to like/follow/post — replies go through PostToX with confirmation. ' +
136
+ 'Typical flow: open → snapshot → click a permalink → snapshot → read content.',
137
+ input_schema: {
138
+ type: 'object',
139
+ properties: {
140
+ action: {
141
+ type: 'string',
142
+ enum: ['open', 'snapshot', 'click', 'scroll', 'screenshot', 'getUrl', 'wait'],
143
+ description: 'open: navigate to a URL. snapshot: capture the page as a ref tree. click: click an element by ref. scroll: scroll vertically by dy px. screenshot: save a PNG. getUrl: return the current URL. wait: pause for ms.',
144
+ },
145
+ url: { type: 'string', description: 'URL for open' },
146
+ ref: { type: 'string', description: 'AX ref (e.g. "2-17") from the last snapshot, for click' },
147
+ dy: { type: 'number', description: 'Vertical scroll delta in px (positive = down). Defaults to 600. Clamped to [-2000, 2000].' },
148
+ ms: { type: 'number', description: 'Milliseconds to wait. Defaults to 2000. Capped at 15000.' },
149
+ path: { type: 'string', description: 'Output path for screenshot. Defaults to ~/.blockrun/screenshots/browsex-<ts>.png.' },
150
+ },
151
+ required: ['action'],
152
+ },
153
+ },
154
+ execute,
155
+ concurrent: false,
156
+ };
@@ -22,6 +22,7 @@ import { askUserCapability } from './askuser.js';
22
22
  import { tradingSignalCapability, tradingMarketCapability } from './trading.js';
23
23
  import { searchXCapability } from './searchx.js';
24
24
  import { postToXCapability } from './posttox.js';
25
+ import { browserXCapability } from './browsex.js';
25
26
  import { moaCapability } from './moa.js';
26
27
  import { webhookPostCapability } from './webhook.js';
27
28
  import { walletCapability } from './wallet.js';
@@ -147,6 +148,7 @@ export const allCapabilities = [
147
148
  ...defaultContentCapabilities, // ContentCreate, ContentAddAsset, ContentShow, ContentList
148
149
  searchXCapability,
149
150
  postToXCapability,
151
+ browserXCapability,
150
152
  moaCapability,
151
153
  webhookPostCapability,
152
154
  walletCapability,
@@ -7,5 +7,6 @@
7
7
  * - **Enhanced** (with social config): adds product routing, dedup, login detection
8
8
  */
9
9
  import type { CapabilityHandler } from '../agent/types.js';
10
+ export declare function canonicalTweetUrl(input: string): string | null;
10
11
  export declare function detectNotificationsIntent(query: string | undefined, handle: string, knownHandles?: string[]): boolean;
11
12
  export declare const searchXCapability: CapabilityHandler;
@@ -12,6 +12,18 @@ import { computePreKey, hasPreKey } from '../social/db.js';
12
12
  import { detectProduct } from '../social/ai.js';
13
13
  import { loadConfig, isConfigReady } from '../social/config.js';
14
14
  import { browserPool } from '../social/browser-pool.js';
15
+ // Detect a tweet permalink the user (or a paste) handed us instead of a
16
+ // keyword. Treat twitter.com and x.com interchangeably; trim the tracking
17
+ // suffix (?s=20 etc.) and normalise to the canonical x.com host so the
18
+ // browser doesn't waste a redirect hop.
19
+ const TWEET_URL_RE = /^https?:\/\/(?:www\.|mobile\.)?(?:x|twitter)\.com\/[^/\s]+\/status\/(\d+)/i;
20
+ export function canonicalTweetUrl(input) {
21
+ const m = TWEET_URL_RE.exec((input ?? '').trim());
22
+ if (!m)
23
+ return null;
24
+ return input.trim().replace(/^https?:\/\/(?:www\.|mobile\.)?(?:x|twitter)\.com/i, 'https://x.com')
25
+ .replace(/[?#].*$/, '');
26
+ }
15
27
  // ─── Intent detection (code-level, not LLM-level) ──────────────────────────
16
28
  // When the user asks "check my @handle mentions/notifications", the tool
17
29
  // itself routes to x.com/notifications. English-only keyword fast-path;
@@ -57,12 +69,111 @@ export function detectNotificationsIntent(query, handle, knownHandles) {
57
69
  return true;
58
70
  return false;
59
71
  }
72
+ async function readTweetByUrl(rawUrl) {
73
+ const url = canonicalTweetUrl(rawUrl) ?? rawUrl;
74
+ let browser;
75
+ try {
76
+ browser = await browserPool.getBrowser();
77
+ try {
78
+ await browser.open(url);
79
+ }
80
+ catch (err) {
81
+ const msg = err instanceof Error ? err.message : String(err);
82
+ return {
83
+ output: `SearchX (url mode): failed to open ${url}: ${msg.slice(0, 200)}`,
84
+ isError: true,
85
+ };
86
+ }
87
+ // Tweet pages are SPAs that lazy-render the article block. A single
88
+ // 4s wait + single snapshot misses content on slow networks or when
89
+ // X briefly shows the auth-wall during hydration even for logged-in
90
+ // sessions. Retry up to 3 times with progressive backoff and a small
91
+ // scroll to nudge the virtual list into rendering.
92
+ let tree = '';
93
+ let articles = [];
94
+ const WAIT_MS = [2500, 4000, 5000];
95
+ let attempt = 0;
96
+ while (attempt < WAIT_MS.length) {
97
+ await browser.waitForTimeout(WAIT_MS[attempt]);
98
+ try {
99
+ tree = await browser.snapshot();
100
+ }
101
+ catch (snapErr) {
102
+ const snapMsg = snapErr instanceof Error ? snapErr.message : String(snapErr);
103
+ return {
104
+ output: `SearchX (url mode): snapshot failed (${snapMsg.slice(0, 100)}). The browser session likely closed mid-flight — retry, or ask the user to run \`franklin social setup\` in a separate terminal.`,
105
+ isError: true,
106
+ };
107
+ }
108
+ if (tree.includes('Rate limit') || tree.includes('Something went wrong')) {
109
+ return {
110
+ output: `SearchX: X returned an error page on ${url} (rate limit or server issue). Try again in a minute.`,
111
+ isError: true,
112
+ };
113
+ }
114
+ if (/this post is unavailable|tweet was deleted|page (doesn'?t|does not) exist|account.*suspended/i.test(tree)) {
115
+ return {
116
+ output: `SearchX: tweet at ${url} is unavailable, deleted, or its author is suspended.`,
117
+ isError: true,
118
+ };
119
+ }
120
+ articles = extractArticleBlocks(tree);
121
+ if (articles.length > 0)
122
+ break;
123
+ // Nudge the page so X mounts the lazy article block.
124
+ try {
125
+ await browser.scroll(400, 400, 0, 400);
126
+ }
127
+ catch { /* ignore */ }
128
+ attempt++;
129
+ }
130
+ const treeLen = tree.length;
131
+ if (articles.length === 0 && tree.includes('Sign in') && tree.includes('Create account')) {
132
+ return {
133
+ output: `SearchX: X is showing a login wall on ${url} after ${WAIT_MS.length} attempts. If you ARE logged in, the cached session may have expired — ask the user to run \`franklin social login x\` in a separate terminal (interactive: opens a Chrome window).`,
134
+ isError: true,
135
+ };
136
+ }
137
+ if (articles.length === 0) {
138
+ return {
139
+ output: `SearchX (url mode): no article extracted from ${url} after ${WAIT_MS.length} attempts. ` +
140
+ `Page rendered ${treeLen} chars. The tweet may load with a non-standard layout — drive the browser ` +
141
+ `directly with BrowserX (action="snapshot" to inspect, action="scroll" to load more, ` +
142
+ `action="open" url=<other> to navigate).\n\n[debug] tree preview:\n${tree.slice(0, 600)}`,
143
+ isError: true,
144
+ };
145
+ }
146
+ const primary = articles[0];
147
+ const texts = findStaticText(primary.text);
148
+ const snippet = texts.join(' ').trim().slice(0, 1200);
149
+ let output = `Tweet at ${url}:\n\n${snippet}\n\n---\n`;
150
+ output += 'IMPORTANT: This is the real post content. ';
151
+ output += 'Do NOT fabricate additional context, replies, or metrics. ';
152
+ output += 'If the user asked for replies/comments, draft them from THIS text only.';
153
+ return { output };
154
+ }
155
+ catch (err) {
156
+ const msg = err instanceof Error ? err.message : String(err);
157
+ return { output: `SearchX (url mode) error: ${msg}`, isError: true };
158
+ }
159
+ finally {
160
+ browserPool.releaseBrowser();
161
+ }
162
+ }
60
163
  async function execute(input, _ctx) {
61
164
  const { query, max_results, mode } = input;
62
165
  if (!query && mode !== 'notifications') {
63
166
  return { output: 'Error: query is required (or set mode to "notifications")', isError: true };
64
167
  }
65
168
  const maxResults = Math.min(Math.max(max_results ?? 10, 1), 50);
169
+ // ── URL fast-path: user pasted a tweet permalink ────────────────────
170
+ // SearchX is the only X-aware tool. If the input is a tweet URL we read
171
+ // the post directly instead of searching for its URL as a keyword (which
172
+ // always returns empty). Triggers on mode="url" OR auto-detected URL.
173
+ const tweetUrl = canonicalTweetUrl(query ?? '');
174
+ if (mode === 'url' || tweetUrl) {
175
+ return await readTweetByUrl(tweetUrl ?? query);
176
+ }
66
177
  // ── Config: load if available, degrade gracefully if not ────────────
67
178
  const config = loadConfig();
68
179
  const configStatus = isConfigReady(config);
@@ -93,7 +204,7 @@ async function execute(input, _ctx) {
93
204
  if (!preflight.ready) {
94
205
  if (isNotifications) {
95
206
  return {
96
- output: 'Not logged in to X. Run `franklin social login x` first — notifications require authentication.',
207
+ output: 'Not logged in to X. Ask the user to run `franklin social login x` in a separate terminal (it opens a Chrome window for them to log in and is NOT runnable by you via Bash) first — notifications require authentication.',
97
208
  isError: true,
98
209
  };
99
210
  }
@@ -144,7 +255,7 @@ async function execute(input, _ctx) {
144
255
  const treeLen = tree.length;
145
256
  if (isLoginWall) {
146
257
  return {
147
- output: `SearchX: X is showing a login wall. Run \`franklin social login x\` to authenticate.\n\nTree preview (${treeLen} chars):\n${tree.slice(0, 500)}`,
258
+ output: `SearchX: X is showing a login wall. Ask the user to run \`franklin social login x\` in a separate terminal (interactive — opens a Chrome window they must drive). Do NOT try to run that command from Bash; it will hang and time out.\n\nTree preview (${treeLen} chars):\n${tree.slice(0, 500)}`,
148
259
  isError: true,
149
260
  };
150
261
  }
@@ -279,20 +390,22 @@ export const searchXCapability = {
279
390
  spec: {
280
391
  name: 'SearchX',
281
392
  description: 'The ONLY tool that can access X (Twitter). Returns real posts with URLs. ' +
282
- 'Use mode "search" to find posts by keyword. Use mode "notifications" to check mentions/replies. ' +
283
- 'Call ONCE per topicdo not retry. WebSearch/WebFetch CANNOT access X.com.',
393
+ 'Use mode "search" to find posts by keyword, "notifications" to check mentions/replies, ' +
394
+ 'or "url" to read a specific tweet you can also just pass a tweet URL as the query and ' +
395
+ 'this tool will auto-detect URL mode. Call ONCE per topic — do not retry. ' +
396
+ 'WebSearch/WebFetch CANNOT access X.com.',
284
397
  input_schema: {
285
398
  type: 'object',
286
399
  properties: {
287
- query: { type: 'string', description: 'Search query (required for search mode, optional for notifications mode)' },
400
+ query: { type: 'string', description: 'Search query for "search" mode, OR a tweet URL (https://x.com/<user>/status/<id>) for "url" mode — URL is auto-detected if passed in query. Optional for "notifications" mode.' },
288
401
  max_results: {
289
402
  type: 'number',
290
- description: 'Max posts to return (default 10)',
403
+ description: 'Max posts to return (default 10, ignored in "url" mode)',
291
404
  },
292
405
  mode: {
293
406
  type: 'string',
294
- enum: ['search', 'notifications'],
295
- description: 'Mode: "search" to find posts by keyword, "notifications" to check your mentions/replies/interactions that need response. Default: search',
407
+ enum: ['search', 'notifications', 'url'],
408
+ description: 'Mode: "search" finds posts by keyword, "notifications" checks your mentions/replies, "url" reads a specific tweet. Default: auto (URL → "url" mode, otherwise "search").',
296
409
  },
297
410
  },
298
411
  required: [],
@@ -237,12 +237,12 @@ const BLOCKED_DOMAINS = [
237
237
  {
238
238
  pattern: /(^|\.)x\.com$/i,
239
239
  reason: 'X.com requires authenticated API',
240
- alternative: 'use SearchX (the dedicated X tool) instead of WebFetch',
240
+ alternative: 'use SearchX. For a specific tweet URL pass it as the query (SearchX auto-detects /status/<id> URLs and reads the post directly). For keyword discovery use mode="search". WebFetch on x.com will not work.',
241
241
  },
242
242
  {
243
243
  pattern: /(^|\.)twitter\.com$/i,
244
244
  reason: 'X.com requires authenticated API',
245
- alternative: 'use SearchX (the dedicated X tool) instead of WebFetch',
245
+ alternative: 'use SearchX. For a specific tweet URL pass it as the query (SearchX auto-detects /status/<id> URLs and reads the post directly). For keyword discovery use mode="search". WebFetch on twitter.com will not work.',
246
246
  },
247
247
  {
248
248
  pattern: /(^|\.)tiktok\.com$/i,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockrun/franklin",
3
- "version": "3.16.3",
3
+ "version": "3.17.0",
4
4
  "description": "Franklin Agent — 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": {