Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
63 commits
Select commit Hold shift + click to select a range
43cdb81
add interactive ACP agent mode for data gathering
developersdigest Mar 26, 2026
dec3160
implement ACP client for agent data gathering
developersdigest Mar 26, 2026
6f44090
fix ACP agent binary names for adapters
developersdigest Mar 26, 2026
80fc884
add conversation loop to interactive agent
developersdigest Mar 26, 2026
c5cdf18
show what firecrawl agent is actually doing
developersdigest Mar 26, 2026
6a1b5a6
clean up tool call display and branding
developersdigest Mar 26, 2026
7a4e4de
redesign tool call UX with spinner and smart filtering
developersdigest Mar 26, 2026
833b57d
multi-line spinner for parallel firecrawl operations
developersdigest Mar 26, 2026
ce17a52
fix spinner spam and clean up tool call display
developersdigest Mar 26, 2026
076ce4d
add ink TUI with status bar, credits, and elapsed time
developersdigest Mar 26, 2026
e4dc8cb
replace ink with lightweight ANSI TUI
developersdigest Mar 26, 2026
72cb7ac
simplify TUI to inline terminal flow
developersdigest Mar 26, 2026
cf64d3f
show all agent activity, not just firecrawl ops
developersdigest Mar 26, 2026
b9dc3fc
deduplicate tool calls and clean up display
developersdigest Mar 26, 2026
cf9c8d5
add pipe mode + rewrite system prompt for better UX
developersdigest Mar 26, 2026
1654f11
add open file/folder prompt at session end
developersdigest Mar 26, 2026
7354d8f
instruct agent to use subagents for scraping
developersdigest Mar 26, 2026
2b58bb8
only show tool calls on completion, not on start
developersdigest Mar 26, 2026
0f59f2f
rewrite TUI with phase sections, usage tracking, and clean layout
developersdigest Mar 26, 2026
026ea95
remove cost display, only show tokens + credits + time
developersdigest Mar 26, 2026
83bf302
show working indicator during background operations
developersdigest Mar 26, 2026
7d38fc5
restrict agent to search + scrape only, no browser
developersdigest Mar 26, 2026
22d4618
show output path at prompt + follow-up suggestions
developersdigest Mar 26, 2026
9a769c2
give agent awareness of session directory for incremental writes
developersdigest Mar 26, 2026
a398dde
default to claude code when available, skip agent selection
developersdigest Mar 26, 2026
28d797a
default to free text input, show examples on empty enter
developersdigest Mar 26, 2026
9cc778d
frame follow-up suggestions as questions, not actions
developersdigest Mar 26, 2026
bd90538
rename extracting phase to gathering data
developersdigest Mar 26, 2026
c93a301
revert tsconfig to original module/resolution settings
developersdigest Mar 26, 2026
26a8b67
Update agent-interactive.ts
developersdigest Mar 26, 2026
5bdbe76
skip planning phase for simple single-source requests
developersdigest Mar 26, 2026
2948cca
trim agent registry to claude code and codex only
developersdigest Mar 26, 2026
a740756
remove dead provider detection from utils/acp.ts
developersdigest Mar 26, 2026
7392a49
always show agent selection as first step
developersdigest Mar 26, 2026
4399fb7
fix line overwrite by ensuring newline before stderr output
developersdigest Mar 26, 2026
5a39cd8
add animated spinner while agent is working in background
developersdigest Mar 26, 2026
2068011
stop agent from narrating internal steps and reading workspace configs
developersdigest Mar 26, 2026
74464fa
remember last-used agent in ~/.firecrawl/preferences.json
developersdigest Mar 26, 2026
52d8d7b
add headless ACP mode for non-interactive agent calls
developersdigest Mar 26, 2026
8b9c457
natural language output for headless mode instead of json
developersdigest Mar 26, 2026
80b0c82
run headless agent in background, return immediately
developersdigest Mar 26, 2026
add0310
write agent.log during headless sessions
developersdigest Mar 26, 2026
f7cbd21
expand firecrawl CLI instructions in system prompt
developersdigest Mar 26, 2026
f493d92
enforce firecrawl for all web access, no alternatives
developersdigest Mar 26, 2026
c702b89
show firecrawl operations when they start, not just on completion
developersdigest Mar 26, 2026
c6b885c
save scraped pages to session dir in sites/<hostname>/<path>/ convention
developersdigest Mar 26, 2026
1706fe1
detect firecrawl MCP tools and built-in web tools, not just CLI
developersdigest Mar 26, 2026
5f8f895
always show agent picker, detect MCP/web tools, enforce CLI usage
developersdigest Mar 26, 2026
050a3f6
remove start line for tool calls, only show on completion
developersdigest Mar 26, 2026
3412ecb
clean up session end menu: only show output file + session folder
developersdigest Mar 26, 2026
468f937
default to follow-up text input, open file menu only on done
developersdigest Mar 26, 2026
f4b3b19
add firecrawl agent --list to browse past sessions
developersdigest Mar 26, 2026
9813452
fix headless worker: use spawn with execArgv instead of fork
developersdigest Mar 26, 2026
41b6772
fix resume: look up ACP binary from registry, not raw provider name
developersdigest Mar 26, 2026
4f75aaf
suggest html visualization in follow-ups when data suits it
developersdigest Mar 26, 2026
d089338
fix resume: add conversation loop, error handling, and ctrl+c support
developersdigest Mar 26, 2026
a7d5230
show ctrl+c hint, detect raw CLIs and suggest ACP adapters
developersdigest Mar 26, 2026
5e1f2cb
add orange ascii AGENT banner on interactive mode
developersdigest Mar 26, 2026
5d39266
expand ascii banner to firecrawl agent
developersdigest Mar 26, 2026
8a3975e
move banner to top of interactive flow, before all prompts
developersdigest Mar 26, 2026
d1ecbc4
add rotating status messages to working spinner
developersdigest Mar 26, 2026
6d4d082
rename agent picker label to harness
developersdigest Mar 26, 2026
5f94d2f
show spinner immediately when starting a new turn
developersdigest Mar 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
"vitest": "^4.0.0"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.0",
"@inquirer/prompts": "^8.2.1",
"@mendable/firecrawl-js": "4.17.0",
"commander": "^14.0.2"
Expand Down
12 changes: 12 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

261 changes: 261 additions & 0 deletions src/acp/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
/**
* Firecrawl ACP Client — connects to any ACP-compatible agent
* (Claude Code, Codex, Gemini CLI, etc.) via the Agent Client Protocol.
*
* Spawns the agent as a subprocess and communicates via JSON-RPC over stdio.
* Uses the official @agentclientprotocol/sdk.
*/

import { spawn, type ChildProcess } from 'child_process';
import { Writable, Readable } from 'stream';
import * as acp from '@agentclientprotocol/sdk';

// ─── Types ──────────────────────────────────────────────────────────────────

export interface ToolCallInfo {
id: string;
title: string;
status: string;
rawInput?: unknown;
rawOutput?: unknown;
}

export interface ACPClientCallbacks {
onText?: (text: string) => void;
onToolCall?: (call: ToolCallInfo) => void;
onToolCallUpdate?: (call: ToolCallInfo) => void;
onPlan?: (entries: Array<{ content: string; status: string }>) => void;
onUsage?: (update: {
size: number;
used: number;
cost?: { amount: number; currency: string } | null;
}) => void;
onPermissionRequest?: (
title: string,
options: Array<{ name: string; optionId: string }>
) => Promise<string>; // returns optionId
}

// ─── Client implementation ──────────────────────────────────────────────────

class FirecrawlClient implements acp.Client {
private callbacks: ACPClientCallbacks;

constructor(callbacks: ACPClientCallbacks) {
this.callbacks = callbacks;
}

async requestPermission(
params: acp.RequestPermissionRequest
): Promise<acp.RequestPermissionResponse> {
// Auto-approve by selecting the first "allow" option
const allowOption = params.options.find(
(o) => o.kind === 'allow_once' || o.kind === 'allow_always'
);
if (allowOption) {
return {
outcome: { outcome: 'selected', optionId: allowOption.optionId },
};
}

// If custom handler provided, let them choose
if (this.callbacks.onPermissionRequest) {
const optionId = await this.callbacks.onPermissionRequest(
params.toolCall.title ?? 'Unknown tool',
params.options.map((o) => ({ name: o.name, optionId: o.optionId }))
);
return { outcome: { outcome: 'selected', optionId } };
}

// Fallback: select first option
return {
outcome: { outcome: 'selected', optionId: params.options[0].optionId },
};
}

async sessionUpdate(params: acp.SessionNotification): Promise<void> {
const update = params.update;

switch (update.sessionUpdate) {
case 'agent_message_chunk':
if (
'content' in update &&
update.content.type === 'text' &&
this.callbacks.onText
) {
this.callbacks.onText(update.content.text);
}
break;

case 'tool_call':
if (this.callbacks.onToolCall) {
this.callbacks.onToolCall({
id: update.toolCallId,
title: update.title ?? 'tool',
status: update.status ?? 'pending',
rawInput: update.rawInput,
rawOutput: update.rawOutput,
});
}
break;

case 'tool_call_update':
if (this.callbacks.onToolCallUpdate) {
this.callbacks.onToolCallUpdate({
id: update.toolCallId,
title: update.title ?? '',
status: update.status ?? 'unknown',
rawInput: update.rawInput,
rawOutput: update.rawOutput,
});
}
break;

case 'plan':
if (this.callbacks.onPlan) {
this.callbacks.onPlan(
update.entries.map((e: { content: string; status: string }) => ({
content: e.content,
status: e.status,
}))
);
}
break;

case 'usage_update':
if (this.callbacks.onUsage) {
this.callbacks.onUsage({
size: update.size,
used: update.used,
cost: update.cost ?? undefined,
});
}
break;

default:
break;
}
}

async writeTextFile(
params: acp.WriteTextFileRequest
): Promise<acp.WriteTextFileResponse> {
const fs = await import('fs');
const path = await import('path');
const dir = path.dirname(params.path);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(params.path, params.content, 'utf-8');
return {};
}

async readTextFile(
params: acp.ReadTextFileRequest
): Promise<acp.ReadTextFileResponse> {
const fs = await import('fs');
const content = fs.readFileSync(params.path, 'utf-8');
return { content };
}
}

// ─── Public API ─────────────────────────────────────────────────────────────

export async function connectToAgent(opts: {
bin: string;
args?: string[];
cwd?: string;
systemPrompt?: string;
callbacks: ACPClientCallbacks;
}): Promise<{
connection: acp.ClientSideConnection;
sessionId: string;
process: ChildProcess;
prompt: (text: string) => Promise<acp.PromptResponse>;
cancel: () => Promise<void>;
close: () => void;
}> {
// Spawn agent subprocess — pipe stderr to suppress agent noise
const agentProcess = spawn(opts.bin, opts.args ?? [], {
stdio: ['pipe', 'pipe', 'pipe'],
cwd: opts.cwd ?? process.cwd(),
});

// Silently discard agent's stderr (hook warnings, debug output, etc.)
agentProcess.stderr?.resume();

// Handle spawn errors
const spawnError = new Promise<never>((_, reject) => {
agentProcess.on('error', (err) => {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
reject(new Error(`Agent "${opts.bin}" not found. Is it installed?`));
} else {
reject(err);
}
});
});

// Create ACP stream from stdio
const input = Writable.toWeb(
agentProcess.stdin!
) as WritableStream<Uint8Array>;
const output = Readable.toWeb(
agentProcess.stdout!
) as ReadableStream<Uint8Array>;
const stream = acp.ndJsonStream(input, output);

// Create client and connection
const client = new FirecrawlClient(opts.callbacks);
const connection = new acp.ClientSideConnection((_agent) => client, stream);

// Initialize (race with spawn error)
await Promise.race([
connection.initialize({
protocolVersion: acp.PROTOCOL_VERSION,
clientCapabilities: {
terminal: true,
fs: {
readTextFile: true,
writeTextFile: true,
},
},
}),
spawnError,
]);

// Create session
const sessionResult = await connection.newSession({
cwd: opts.cwd ?? process.cwd(),
mcpServers: [],
});

const sessionId = sessionResult.sessionId;

// Store system prompt to prepend to first message
const systemContext = opts.systemPrompt;

return {
connection,
sessionId,
process: agentProcess,

async prompt(text: string) {
// Prepend system prompt as context in the first message
const fullText = systemContext
? `<system-context>\n${systemContext}\n</system-context>\n\n${text}`
: text;
return connection.prompt({
sessionId,
prompt: [{ type: 'text', text: fullText }],
});
},

async cancel() {
await connection.cancel({ sessionId });
},

close() {
agentProcess.kill();
},
};
}
33 changes: 33 additions & 0 deletions src/acp/registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* ACP agent registry — detect installed ACP-compatible agents.
*/

import { execSync } from 'child_process';

export interface ACPAgent {
name: string;
bin: string;
displayName: string;
available: boolean;
}

const KNOWN_AGENTS: Omit<ACPAgent, 'available'>[] = [
{ name: 'claude', bin: 'claude-agent-acp', displayName: 'Claude Code' },
{ name: 'codex', bin: 'codex-acp', displayName: 'Codex' },
];

function isBinAvailable(bin: string): boolean {
try {
execSync(`which ${bin}`, { stdio: 'ignore' });
return true;
} catch {
return false;
}
}

export function detectAgents(): ACPAgent[] {
return KNOWN_AGENTS.map((a) => ({
...a,
available: isBinAvailable(a.bin),
}));
}
Loading
Loading