@blockrun/franklin 3.26.0 → 3.27.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/dist/agent/llm.js +9 -1
- package/dist/agent/loop.js +14 -4
- package/dist/agent/streaming-executor.js +28 -2
- package/dist/agent/types.d.ts +4 -0
- package/dist/channel/slack.d.ts +57 -0
- package/dist/channel/slack.js +370 -0
- package/dist/channel/telegram.d.ts +4 -0
- package/dist/channel/telegram.js +85 -16
- package/dist/commands/config.d.ts +7 -0
- package/dist/commands/config.js +7 -0
- package/dist/commands/serve.d.ts +7 -0
- package/dist/commands/serve.js +7 -0
- package/dist/commands/slack.d.ts +15 -0
- package/dist/commands/slack.js +118 -0
- package/dist/commands/telegram.js +9 -0
- package/dist/index.js +23 -1
- package/dist/pricing.js +3 -3
- package/dist/serve/cloud-sync.d.ts +23 -0
- package/dist/serve/cloud-sync.js +120 -0
- package/dist/serve/server.d.ts +25 -0
- package/dist/serve/server.js +767 -0
- package/dist/stats/swap-log.d.ts +19 -0
- package/dist/stats/swap-log.js +33 -0
- package/dist/tools/zerox-base.js +36 -1
- package/dist/tools/zerox-gasless.js +17 -0
- package/dist/ui/model-picker.js +3 -10
- package/package.json +2 -1
|
@@ -0,0 +1,767 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Franklin agent server (local WebSocket — drives the desktop app & browser UI).
|
|
3
|
+
*
|
|
4
|
+
* Serves the local React WebUI (franklin-webui / the desktop app) over a single
|
|
5
|
+
* WebSocket using the envelope wire protocol the UI already speaks:
|
|
6
|
+
*
|
|
7
|
+
* client → { id, kind, payload } (agent.send / session.* / wallet.info / …)
|
|
8
|
+
* server → { id, kind, payload } (agent.text / agent.step / agent.done / …)
|
|
9
|
+
*
|
|
10
|
+
* Unlike `franklin panel` (a read-only dashboard), this actually runs agent
|
|
11
|
+
* turns: it drives the real `interactiveSession` loop from src/agent/loop.ts —
|
|
12
|
+
* same tools, wallet, routing and signing as the CLI. The browser/desktop is
|
|
13
|
+
* just a different head on the same agent.
|
|
14
|
+
*
|
|
15
|
+
* Single-window assumption: one long-lived agent session per server process,
|
|
16
|
+
* fed by a getUserInput queue. Good enough for the desktop app; multi-session
|
|
17
|
+
* fan-out can come later.
|
|
18
|
+
*/
|
|
19
|
+
import http from 'node:http';
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import WebSocket from 'ws';
|
|
23
|
+
import { loadChain, API_URLS, BLOCKRUN_DIR } from '../config.js';
|
|
24
|
+
import { loadConfig, setConfigValue } from '../commands/config.js';
|
|
25
|
+
import { assembleInstructions } from '../agent/context.js';
|
|
26
|
+
import { interactiveSession } from '../agent/loop.js';
|
|
27
|
+
import { allCapabilities, createSubAgentCapability } from '../tools/index.js';
|
|
28
|
+
import { getModelsByCategory } from '../gateway-models.js';
|
|
29
|
+
import { listSessions, loadSessionHistory } from '../session/storage.js';
|
|
30
|
+
import { loadSdkSettlements } from '../stats/cost-log.js';
|
|
31
|
+
import { readSwaps } from '../stats/swap-log.js';
|
|
32
|
+
import { isCloudSyncEnabled, cloudList, cloudSync } from './cloud-sync.js';
|
|
33
|
+
import { setupAgentWallet, setupAgentSolanaWallet } from '@blockrun/llm';
|
|
34
|
+
import { retryFetchBalance } from '../commands/balance-retry.js';
|
|
35
|
+
const FREE_DEFAULT_MODEL = 'nvidia/deepseek-v4-flash';
|
|
36
|
+
// Curated Base (chainId 8453) tokens for the wallet "holdings" view. Plain RPC
|
|
37
|
+
// can't enumerate every token an address holds (no on-chain "list all"), so we
|
|
38
|
+
// balanceOf a known set and show the non-zero ones. `stable` → USD ≈ amount.
|
|
39
|
+
const BASE_PUBLIC_RPCS = ['https://base.publicnode.com', 'https://mainnet.base.org', 'https://base.meowrpc.com'];
|
|
40
|
+
const BASE_TOKENS = [
|
|
41
|
+
{ symbol: 'USDC', address: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', decimals: 6, stable: true },
|
|
42
|
+
{ symbol: 'USDbC', address: '0xd9aAEc86B65D86f6A7B5B1b0c42FFA531710b6CA', decimals: 6, stable: true },
|
|
43
|
+
{ symbol: 'USDT', address: '0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2', decimals: 6, stable: true },
|
|
44
|
+
{ symbol: 'DAI', address: '0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb', decimals: 18, stable: true },
|
|
45
|
+
{ symbol: 'WETH', address: '0x4200000000000000000000000000000000000006', decimals: 18, cg: 'ethereum' },
|
|
46
|
+
{ symbol: 'cbBTC', address: '0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf', decimals: 8, cg: 'bitcoin' },
|
|
47
|
+
{ symbol: 'cbETH', address: '0x2Ae3F1Ec7F1F5012CFEab0185bfc7aa3cf0DEc22', decimals: 18, cg: 'ethereum' },
|
|
48
|
+
{ symbol: 'AERO', address: '0x940181a94A35A4569E4529A3CDfB74e38FD98631', decimals: 18, cg: 'aerodrome-finance' },
|
|
49
|
+
{ symbol: 'DEGEN', address: '0x4ed4E862860beD51a9570b96d89aF5E1B0Efefed', decimals: 18, cg: 'degen-base' },
|
|
50
|
+
];
|
|
51
|
+
// USD prices via CoinGecko (free, no key). Best-effort: on failure, tokens just
|
|
52
|
+
// show without a USD value rather than blocking the holdings list.
|
|
53
|
+
async function fetchCgPrices(ids) {
|
|
54
|
+
if (ids.length === 0)
|
|
55
|
+
return {};
|
|
56
|
+
try {
|
|
57
|
+
const url = `https://api.coingecko.com/api/v3/simple/price?ids=${encodeURIComponent([...new Set(ids)].join(','))}&vs_currencies=usd`;
|
|
58
|
+
const r = await fetch(url, { signal: AbortSignal.timeout(6000) });
|
|
59
|
+
const j = await r.json();
|
|
60
|
+
const out = {};
|
|
61
|
+
for (const [k, v] of Object.entries(j))
|
|
62
|
+
if (typeof v?.usd === 'number')
|
|
63
|
+
out[k] = v.usd;
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return {};
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
async function baseRpc(method, params) {
|
|
71
|
+
for (const url of BASE_PUBLIC_RPCS) {
|
|
72
|
+
try {
|
|
73
|
+
const r = await fetch(url, {
|
|
74
|
+
method: 'POST',
|
|
75
|
+
headers: { 'Content-Type': 'application/json' },
|
|
76
|
+
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
|
|
77
|
+
signal: AbortSignal.timeout(6000),
|
|
78
|
+
});
|
|
79
|
+
const j = await r.json();
|
|
80
|
+
if (j && typeof j.result === 'string')
|
|
81
|
+
return j.result;
|
|
82
|
+
}
|
|
83
|
+
catch { /* try next rpc */ }
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
function hexToAmount(hex, decimals) {
|
|
88
|
+
if (!hex || hex === '0x')
|
|
89
|
+
return 0;
|
|
90
|
+
try {
|
|
91
|
+
const v = BigInt(hex);
|
|
92
|
+
if (v === 0n)
|
|
93
|
+
return 0;
|
|
94
|
+
// Scale down with enough precision for display.
|
|
95
|
+
return Number(v) / 10 ** decimals;
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return 0;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** Best-effort list of an address's holdings (native ETH + curated ERC-20s). */
|
|
102
|
+
async function listBaseHoldings(address) {
|
|
103
|
+
const out = [];
|
|
104
|
+
const addr = address.toLowerCase().replace(/^0x/, '').padStart(64, '0');
|
|
105
|
+
const DUST = 1e-6; // hide negligible dust (e.g. post-swap leftovers) that'd render as "0"
|
|
106
|
+
// Collect raw balances (amount + which coingecko id to price it with).
|
|
107
|
+
const raw = [];
|
|
108
|
+
const ethHex = await baseRpc('eth_getBalance', [address, 'latest']);
|
|
109
|
+
const eth = hexToAmount(ethHex, 18);
|
|
110
|
+
if (eth >= DUST)
|
|
111
|
+
raw.push({ symbol: 'ETH', amount: eth, cg: 'ethereum' });
|
|
112
|
+
await Promise.all(BASE_TOKENS.map(async (t) => {
|
|
113
|
+
const data = '0x70a08231' + addr; // balanceOf(address)
|
|
114
|
+
const hex = await baseRpc('eth_call', [{ to: t.address, data }, 'latest']);
|
|
115
|
+
const amt = hexToAmount(hex, t.decimals);
|
|
116
|
+
if (amt >= DUST)
|
|
117
|
+
raw.push({ symbol: t.symbol, amount: amt, stable: t.stable, cg: t.cg });
|
|
118
|
+
}));
|
|
119
|
+
// Price the non-stable holdings (stable ≈ $1) and compute USD value per token.
|
|
120
|
+
const prices = await fetchCgPrices(raw.filter((r) => !r.stable && r.cg).map((r) => r.cg));
|
|
121
|
+
for (const r of raw) {
|
|
122
|
+
const usd = r.stable ? r.amount : (r.cg && prices[r.cg] != null ? r.amount * prices[r.cg] : undefined);
|
|
123
|
+
out.push({ symbol: r.symbol, amount: r.amount, ...(usd != null ? { usd } : {}) });
|
|
124
|
+
}
|
|
125
|
+
return out.sort((a, b) => (b.usd ?? 0) - (a.usd ?? 0) || b.amount - a.amount);
|
|
126
|
+
}
|
|
127
|
+
// Friendly, provider-tagged labels for the activity log (mirrors franklin-run),
|
|
128
|
+
// so a finished step reads "Checking prediction markets · Predexon" instead of
|
|
129
|
+
// the raw tool name. Unknown tools fall back to their own name.
|
|
130
|
+
const TOOL_LABELS = {
|
|
131
|
+
web_search: 'Searching the web · Exa',
|
|
132
|
+
search_prediction_markets: 'Checking prediction markets · Predexon',
|
|
133
|
+
get_market_price: 'Fetching live price',
|
|
134
|
+
generate_music: 'Composing music',
|
|
135
|
+
make_phone_call: 'Placing phone call',
|
|
136
|
+
};
|
|
137
|
+
function labelFor(name) {
|
|
138
|
+
return TOOL_LABELS[name] ?? name;
|
|
139
|
+
}
|
|
140
|
+
// Model list grouping — by provider (company), like OpenRouter/Together. The
|
|
141
|
+
// provider is the id's vendor prefix (e.g. "anthropic/claude-…"); PROVIDER_ORDER
|
|
142
|
+
// puts the most-wanted vendors first, the rest fall in alphabetically.
|
|
143
|
+
const PROVIDER_LABEL = {
|
|
144
|
+
anthropic: 'Anthropic', openai: 'OpenAI', azure: 'OpenAI', google: 'Google', 'google-vertex': 'Google',
|
|
145
|
+
xai: 'xAI', deepseek: 'DeepSeek', meta: 'Meta', 'meta-llama': 'Meta', nvidia: 'NVIDIA',
|
|
146
|
+
moonshot: 'Moonshot', moonshotai: 'Moonshot', qwen: 'Qwen', alibaba: 'Qwen', mistral: 'Mistral',
|
|
147
|
+
mistralai: 'Mistral', minimax: 'MiniMax', zhipu: 'Zhipu', bytedance: 'ByteDance', cohere: 'Cohere',
|
|
148
|
+
perplexity: 'Perplexity', amazon: 'Amazon', microsoft: 'Microsoft', '01-ai': 'Yi', ai21: 'AI21',
|
|
149
|
+
};
|
|
150
|
+
const PROVIDER_ORDER = ['Anthropic', 'OpenAI', 'Google', 'xAI', 'DeepSeek', 'Qwen', 'Moonshot', 'Meta', 'Mistral', 'MiniMax', 'NVIDIA'];
|
|
151
|
+
function providerLabel(id, ownedBy) {
|
|
152
|
+
const p = (id.split('/')[0] || ownedBy || '').toLowerCase();
|
|
153
|
+
return PROVIDER_LABEL[p] || (p ? p.charAt(0).toUpperCase() + p.slice(1) : 'Other');
|
|
154
|
+
}
|
|
155
|
+
// ─── Browser-attack surface gate ────────────────────────────────────────────
|
|
156
|
+
// Loopback binding alone is NOT an auth boundary: any web page the user has
|
|
157
|
+
// open can reach 127.0.0.1 (the browser attaches an Origin header but happily
|
|
158
|
+
// completes the request — WS handshakes aren't blocked by CORS, and a wallet-
|
|
159
|
+
// bearing agent in trust mode must not be drivable by a drive-by page).
|
|
160
|
+
//
|
|
161
|
+
// Policy: requests WITHOUT an Origin header are local processes (Electron main,
|
|
162
|
+
// curl, native clients — browsers can't strip Origin) → allowed. Browser
|
|
163
|
+
// origins are allowed only for Electron renderers (file:// / app://), local
|
|
164
|
+
// UIs (localhost / 127.0.0.1), the hosted web UI, and anything listed in
|
|
165
|
+
// FRANKLIN_SERVE_ALLOWED_ORIGINS (comma-separated). The literal "null" origin
|
|
166
|
+
// is REJECTED by default — sandboxed iframes on hostile pages also serialize
|
|
167
|
+
// to "null" — set FRANKLIN_SERVE_ALLOW_NULL_ORIGIN=1 if a renderer needs it.
|
|
168
|
+
// Defense-in-depth: when FRANKLIN_SERVE_TOKEN is set, every WS upgrade and
|
|
169
|
+
// /file request must also carry it (?token=…).
|
|
170
|
+
const DEFAULT_ALLOWED_ORIGINS = ['https://franklin.run'];
|
|
171
|
+
function isOriginAllowed(origin) {
|
|
172
|
+
if (!origin)
|
|
173
|
+
return true; // non-browser local client
|
|
174
|
+
if (origin === 'null')
|
|
175
|
+
return process.env.FRANKLIN_SERVE_ALLOW_NULL_ORIGIN === '1';
|
|
176
|
+
if (origin.startsWith('file://') || origin.startsWith('app://'))
|
|
177
|
+
return true; // Electron renderer
|
|
178
|
+
let host = '';
|
|
179
|
+
try {
|
|
180
|
+
host = new URL(origin).hostname;
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
if (host === '127.0.0.1' || host === 'localhost' || host === '[::1]' || host === '::1')
|
|
186
|
+
return true;
|
|
187
|
+
const extra = (process.env.FRANKLIN_SERVE_ALLOWED_ORIGINS || '')
|
|
188
|
+
.split(',').map((s) => s.trim()).filter(Boolean);
|
|
189
|
+
return [...DEFAULT_ALLOWED_ORIGINS, ...extra].includes(origin);
|
|
190
|
+
}
|
|
191
|
+
function tokenOk(url) {
|
|
192
|
+
const required = process.env.FRANKLIN_SERVE_TOKEN;
|
|
193
|
+
if (!required)
|
|
194
|
+
return true;
|
|
195
|
+
return url.searchParams.get('token') === required;
|
|
196
|
+
}
|
|
197
|
+
function send(ws, id, kind, payload) {
|
|
198
|
+
if (ws.readyState !== WebSocket.OPEN)
|
|
199
|
+
return;
|
|
200
|
+
ws.send(JSON.stringify({ id, kind, payload }));
|
|
201
|
+
}
|
|
202
|
+
// Flatten a stored Dialogue into the {role, content, kind:'text'} shape the UI
|
|
203
|
+
// renders. Tool calls / images are dropped here (the live stream carries those
|
|
204
|
+
// for the active turn); history replay just needs the text.
|
|
205
|
+
function dialogueText(content) {
|
|
206
|
+
if (typeof content === 'string')
|
|
207
|
+
return content;
|
|
208
|
+
const parts = content;
|
|
209
|
+
return parts
|
|
210
|
+
.map((p) => (p && typeof p === 'object' && 'type' in p && p.type === 'text' ? p.text : ''))
|
|
211
|
+
.filter(Boolean)
|
|
212
|
+
.join('');
|
|
213
|
+
}
|
|
214
|
+
export async function startServer(opts) {
|
|
215
|
+
const { port, workDir, debug } = opts;
|
|
216
|
+
const chain = loadChain();
|
|
217
|
+
const apiUrl = API_URLS[chain];
|
|
218
|
+
const userConfig = loadConfig();
|
|
219
|
+
// ── Single long-lived agent session ──
|
|
220
|
+
// interactiveSession owns the loop; we feed it user turns via a queue and
|
|
221
|
+
// fan its StreamEvents out to the connected socket.
|
|
222
|
+
let sessionStarted = false;
|
|
223
|
+
let currentModel = null;
|
|
224
|
+
// Live config ref + the cost-saver (research-bloat compaction) toggle. The UI
|
|
225
|
+
// flips this; we mutate the running config so the loop picks it up next turn.
|
|
226
|
+
let agentConfig = null;
|
|
227
|
+
let costSaver = userConfig['cost-saver'] !== 'false';
|
|
228
|
+
let inputQueue = [];
|
|
229
|
+
let inputResolver = null;
|
|
230
|
+
let abortFn = null;
|
|
231
|
+
// The socket + correlation id for the in-flight turn (single-window).
|
|
232
|
+
let activeWs = null;
|
|
233
|
+
let activeTurnId = null;
|
|
234
|
+
// We sometimes inject helper commands (`/model …`, `/clear`) as their own
|
|
235
|
+
// turns ahead of the real prompt. Each ends with its own turn_done — which
|
|
236
|
+
// would emit agent.done and clear activeTurnId, killing the real prompt's
|
|
237
|
+
// stream. This counter swallows each injected turn's events (text + turn_done)
|
|
238
|
+
// so the real prompt streams next under the same activeTurnId. It's a counter,
|
|
239
|
+
// not a bool, because a single send can inject more than one command.
|
|
240
|
+
let suppressTurns = 0;
|
|
241
|
+
// The client conversation id the running agent history belongs to. When a turn
|
|
242
|
+
// arrives for a different conversation we /clear the history so separate
|
|
243
|
+
// sidebar chats don't bleed context (the server runs one long-lived session).
|
|
244
|
+
let currentConvId = null;
|
|
245
|
+
const stepIds = new Map();
|
|
246
|
+
const stepLabels = new Map();
|
|
247
|
+
const stepDetails = new Map();
|
|
248
|
+
let stepSeq = 0;
|
|
249
|
+
function getUserInput() {
|
|
250
|
+
return new Promise((resolve) => {
|
|
251
|
+
if (inputQueue.length > 0) {
|
|
252
|
+
resolve(inputQueue.shift());
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
inputResolver = resolve;
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
function pushInput(text) {
|
|
259
|
+
if (inputResolver) {
|
|
260
|
+
const r = inputResolver;
|
|
261
|
+
inputResolver = null;
|
|
262
|
+
r(text);
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
inputQueue.push(text);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
function emit(kind, payload) {
|
|
269
|
+
if (activeWs && activeTurnId)
|
|
270
|
+
send(activeWs, activeTurnId, kind, payload);
|
|
271
|
+
}
|
|
272
|
+
// ── Wallet balance (cached client) + post-turn broadcast ──
|
|
273
|
+
let walletClient = null;
|
|
274
|
+
async function getWallet() {
|
|
275
|
+
if (walletClient)
|
|
276
|
+
return walletClient;
|
|
277
|
+
const c = chain === 'solana'
|
|
278
|
+
? await setupAgentSolanaWallet({ silent: true })
|
|
279
|
+
: setupAgentWallet({ silent: true });
|
|
280
|
+
walletClient = c;
|
|
281
|
+
return c;
|
|
282
|
+
}
|
|
283
|
+
async function fetchBalanceUsd() {
|
|
284
|
+
try {
|
|
285
|
+
const client = await getWallet();
|
|
286
|
+
return await retryFetchBalance(() => client.getBalance());
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
return undefined;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
// After each turn, push the fresh balance to the UI (settlement may have
|
|
293
|
+
// changed it) so the sidebar pill + wallet page update live and stay in sync.
|
|
294
|
+
// Broadcast with a non-turn id so it reaches the client's global listeners.
|
|
295
|
+
function broadcastWalletAfterTurn() {
|
|
296
|
+
const ws = activeWs;
|
|
297
|
+
if (!ws)
|
|
298
|
+
return;
|
|
299
|
+
void fetchBalanceUsd().then((balanceUsd) => {
|
|
300
|
+
if (balanceUsd != null)
|
|
301
|
+
send(ws, 'wallet', 'wallet.event', { balanceUsd });
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
function onEvent(event) {
|
|
305
|
+
// Injected helper turn (/model, /clear): drop its output and end-of-turn so
|
|
306
|
+
// it neither shows in the chat nor closes the real prompt's stream.
|
|
307
|
+
if (suppressTurns > 0) {
|
|
308
|
+
if (event.kind === 'turn_done')
|
|
309
|
+
suppressTurns--;
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
switch (event.kind) {
|
|
313
|
+
case 'text_delta':
|
|
314
|
+
// Drop internal compaction status lines (🗜 …) — they're CLI ops noise,
|
|
315
|
+
// not part of the answer, and shouldn't render in the desktop chat.
|
|
316
|
+
if (/^\s*\*?🗜/.test(event.text))
|
|
317
|
+
break;
|
|
318
|
+
emit('agent.text', { sessionId: '', text: event.text });
|
|
319
|
+
break;
|
|
320
|
+
case 'capability_start': {
|
|
321
|
+
let sid = stepIds.get(event.id);
|
|
322
|
+
if (sid == null) {
|
|
323
|
+
sid = ++stepSeq;
|
|
324
|
+
stepIds.set(event.id, sid);
|
|
325
|
+
}
|
|
326
|
+
const label = labelFor(event.name);
|
|
327
|
+
stepLabels.set(event.id, label);
|
|
328
|
+
// The per-call detail (the tool's key argument — query, prompt, symbol…)
|
|
329
|
+
// shown as small text next to the tool so you see WHAT it's doing.
|
|
330
|
+
const detail = event.preview?.trim() || '';
|
|
331
|
+
if (detail)
|
|
332
|
+
stepDetails.set(event.id, detail);
|
|
333
|
+
emit('agent.step', { sessionId: '', stepId: sid, label, detail, state: 'run' });
|
|
334
|
+
break;
|
|
335
|
+
}
|
|
336
|
+
case 'capability_done': {
|
|
337
|
+
const sid = stepIds.get(event.id) ?? ++stepSeq;
|
|
338
|
+
// Keep the original label on completion — sending '' here is what made
|
|
339
|
+
// finished steps render as a bare checkmark with no text.
|
|
340
|
+
emit('agent.step', { sessionId: '', stepId: sid, label: stepLabels.get(event.id) ?? '', detail: stepDetails.get(event.id) ?? '', state: 'done' });
|
|
341
|
+
const images = event.result?.images;
|
|
342
|
+
if (images && images.length) {
|
|
343
|
+
emit('agent.tool_result', {
|
|
344
|
+
sessionId: '',
|
|
345
|
+
toolCallId: event.id,
|
|
346
|
+
preview: event.result.output ?? '',
|
|
347
|
+
isError: event.result.isError,
|
|
348
|
+
artifacts: images.map((im) => ({
|
|
349
|
+
path: `data:${im.mediaType};base64,${im.base64}`,
|
|
350
|
+
mediaType: im.mediaType,
|
|
351
|
+
})),
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
// MusicGen / media tools save a local file and report its path in the
|
|
355
|
+
// output text. Surface generated audio (and stand-alone video/image
|
|
356
|
+
// files) as a playable artifact served over the /file route.
|
|
357
|
+
const out = event.result?.output ?? '';
|
|
358
|
+
const fileMatch = out.match(/(\/[^\s'"]*\.(?:mp3|wav|m4a|ogg|flac|mp4|webm))/i);
|
|
359
|
+
if (fileMatch) {
|
|
360
|
+
const filePath = fileMatch[1];
|
|
361
|
+
const ext = filePath.toLowerCase().split('.').pop() || '';
|
|
362
|
+
const mediaType = ext === 'mp4' || ext === 'webm' ? `video/${ext}` :
|
|
363
|
+
ext === 'mp3' ? 'audio/mpeg' :
|
|
364
|
+
ext === 'm4a' ? 'audio/mp4' : `audio/${ext}`;
|
|
365
|
+
emit('agent.tool_result', {
|
|
366
|
+
sessionId: '',
|
|
367
|
+
toolCallId: event.id,
|
|
368
|
+
preview: '',
|
|
369
|
+
artifacts: [{ path: `http://127.0.0.1:${port}/file?path=${encodeURIComponent(filePath)}`, mediaType }],
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
break;
|
|
373
|
+
}
|
|
374
|
+
case 'turn_done':
|
|
375
|
+
if (event.reason === 'completed') {
|
|
376
|
+
emit('agent.done', { sessionId: '', costUsd: 0 });
|
|
377
|
+
}
|
|
378
|
+
else if (event.error) {
|
|
379
|
+
emit('agent.error', { sessionId: '', message: event.error });
|
|
380
|
+
}
|
|
381
|
+
else {
|
|
382
|
+
emit('agent.done', { sessionId: '', costUsd: 0 });
|
|
383
|
+
}
|
|
384
|
+
activeTurnId = null;
|
|
385
|
+
stepIds.clear();
|
|
386
|
+
stepLabels.clear();
|
|
387
|
+
stepDetails.clear();
|
|
388
|
+
broadcastWalletAfterTurn();
|
|
389
|
+
break;
|
|
390
|
+
// thinking_delta / capability_input_delta / capability_progress / usage:
|
|
391
|
+
// not surfaced to the UI yet.
|
|
392
|
+
default:
|
|
393
|
+
break;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
async function ensureSession(model) {
|
|
397
|
+
if (sessionStarted)
|
|
398
|
+
return;
|
|
399
|
+
sessionStarted = true;
|
|
400
|
+
currentModel = model;
|
|
401
|
+
const systemInstructions = assembleInstructions(workDir, model);
|
|
402
|
+
const subAgent = createSubAgentCapability(apiUrl, chain, allCapabilities, model);
|
|
403
|
+
try {
|
|
404
|
+
const { registerMoAConfig } = await import('../tools/moa.js');
|
|
405
|
+
registerMoAConfig(apiUrl, chain, model);
|
|
406
|
+
}
|
|
407
|
+
catch { /* MoA optional */ }
|
|
408
|
+
const capabilities = [...allCapabilities, subAgent];
|
|
409
|
+
const config = {
|
|
410
|
+
model,
|
|
411
|
+
apiUrl,
|
|
412
|
+
chain,
|
|
413
|
+
systemInstructions,
|
|
414
|
+
capabilities,
|
|
415
|
+
maxTurns: 100,
|
|
416
|
+
workingDir: workDir,
|
|
417
|
+
permissionMode: 'trust', // the desktop UI has no permission prompt yet
|
|
418
|
+
debug: !!debug,
|
|
419
|
+
showPrefetchStatus: false,
|
|
420
|
+
costSaver,
|
|
421
|
+
};
|
|
422
|
+
agentConfig = config;
|
|
423
|
+
interactiveSession(config, getUserInput, onEvent, (abort) => { abortFn = abort; })
|
|
424
|
+
.catch((err) => {
|
|
425
|
+
if (activeWs && activeTurnId) {
|
|
426
|
+
send(activeWs, activeTurnId, 'agent.error', { sessionId: '', message: err instanceof Error ? err.message : String(err) });
|
|
427
|
+
}
|
|
428
|
+
})
|
|
429
|
+
.finally(() => { sessionStarted = false; abortFn = null; });
|
|
430
|
+
}
|
|
431
|
+
// ── RPC handlers ──
|
|
432
|
+
async function handle(ws, msg) {
|
|
433
|
+
const { id, kind, payload } = msg;
|
|
434
|
+
const p = (payload ?? {});
|
|
435
|
+
switch (kind) {
|
|
436
|
+
case 'session.list': {
|
|
437
|
+
const metas = listSessions();
|
|
438
|
+
send(ws, id, 'response', {
|
|
439
|
+
sessions: metas.map((m) => ({
|
|
440
|
+
id: m.id,
|
|
441
|
+
title: `${m.model} · ${m.id.slice(0, 6)}`,
|
|
442
|
+
createdAt: m.createdAt,
|
|
443
|
+
updatedAt: m.updatedAt,
|
|
444
|
+
messageCount: m.messageCount ?? 0,
|
|
445
|
+
lastModel: m.model,
|
|
446
|
+
})),
|
|
447
|
+
});
|
|
448
|
+
break;
|
|
449
|
+
}
|
|
450
|
+
case 'session.load': {
|
|
451
|
+
const history = loadSessionHistory(String(p.id ?? ''));
|
|
452
|
+
const messages = history
|
|
453
|
+
.filter((d) => d.role === 'user' || d.role === 'assistant')
|
|
454
|
+
.map((d) => ({ role: d.role, content: dialogueText(d.content), kind: 'text' }))
|
|
455
|
+
.filter((m) => m.content);
|
|
456
|
+
send(ws, id, 'response', { messages });
|
|
457
|
+
break;
|
|
458
|
+
}
|
|
459
|
+
case 'wallet.info': {
|
|
460
|
+
try {
|
|
461
|
+
const client = await getWallet();
|
|
462
|
+
const address = client.getWalletAddress();
|
|
463
|
+
const balanceUsd = await fetchBalanceUsd(); // best-effort; undefined on failure
|
|
464
|
+
send(ws, id, 'response', { address, chain, balanceUsd });
|
|
465
|
+
}
|
|
466
|
+
catch (err) {
|
|
467
|
+
send(ws, id, 'error', { message: err instanceof Error ? err.message : 'wallet error' });
|
|
468
|
+
}
|
|
469
|
+
break;
|
|
470
|
+
}
|
|
471
|
+
case 'wallet.tokens': {
|
|
472
|
+
// Holdings: native ETH + curated Base ERC-20s with a non-zero balance.
|
|
473
|
+
// Public RPC can't enumerate ALL tokens, so this is a known-token sweep.
|
|
474
|
+
try {
|
|
475
|
+
if (chain !== 'base') {
|
|
476
|
+
send(ws, id, 'response', { tokens: [] });
|
|
477
|
+
break;
|
|
478
|
+
}
|
|
479
|
+
const client = await getWallet();
|
|
480
|
+
const address = await client.getWalletAddress();
|
|
481
|
+
const tokens = await listBaseHoldings(address);
|
|
482
|
+
send(ws, id, 'response', { tokens });
|
|
483
|
+
}
|
|
484
|
+
catch (err) {
|
|
485
|
+
send(ws, id, 'error', { message: err instanceof Error ? err.message : 'tokens error' });
|
|
486
|
+
}
|
|
487
|
+
break;
|
|
488
|
+
}
|
|
489
|
+
case 'history.load': {
|
|
490
|
+
// History is wallet-synced to the cloud (franklin.run, same as the web)
|
|
491
|
+
// with a local file (~/.blockrun) as cache + offline fallback. Cloud is
|
|
492
|
+
// the source of truth when reachable; otherwise we serve the local file.
|
|
493
|
+
const file = path.join(BLOCKRUN_DIR, 'franklin-desktop-history.json');
|
|
494
|
+
const readLocal = () => {
|
|
495
|
+
try {
|
|
496
|
+
if (fs.existsSync(file)) {
|
|
497
|
+
const p2 = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
498
|
+
if (Array.isArray(p2))
|
|
499
|
+
return p2;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
catch { /* ignore */ }
|
|
503
|
+
return [];
|
|
504
|
+
};
|
|
505
|
+
const writeLocal = (c) => {
|
|
506
|
+
try {
|
|
507
|
+
fs.mkdirSync(BLOCKRUN_DIR, { recursive: true });
|
|
508
|
+
fs.writeFileSync(file, JSON.stringify(c), { mode: 0o600 });
|
|
509
|
+
}
|
|
510
|
+
catch { /* ignore */ }
|
|
511
|
+
};
|
|
512
|
+
try {
|
|
513
|
+
const local = readLocal();
|
|
514
|
+
if (isCloudSyncEnabled()) {
|
|
515
|
+
try {
|
|
516
|
+
const cloud = await cloudList();
|
|
517
|
+
if (cloud.length > 0) {
|
|
518
|
+
writeLocal(cloud); // refresh local cache
|
|
519
|
+
send(ws, id, 'response', { conversations: cloud });
|
|
520
|
+
break;
|
|
521
|
+
}
|
|
522
|
+
// Cloud empty but we have local history → migrate it up.
|
|
523
|
+
if (local.length > 0)
|
|
524
|
+
void cloudSync(local).catch(() => { });
|
|
525
|
+
}
|
|
526
|
+
catch { /* offline / not-deployed → fall back to local */ }
|
|
527
|
+
}
|
|
528
|
+
send(ws, id, 'response', { conversations: local });
|
|
529
|
+
}
|
|
530
|
+
catch (err) {
|
|
531
|
+
send(ws, id, 'error', { message: err instanceof Error ? err.message : 'history load error' });
|
|
532
|
+
}
|
|
533
|
+
break;
|
|
534
|
+
}
|
|
535
|
+
case 'history.save': {
|
|
536
|
+
try {
|
|
537
|
+
const conversations = Array.isArray(p.conversations) ? p.conversations : [];
|
|
538
|
+
// Local file = instant durable cache; cloud = best-effort wallet sync.
|
|
539
|
+
fs.mkdirSync(BLOCKRUN_DIR, { recursive: true });
|
|
540
|
+
fs.writeFileSync(path.join(BLOCKRUN_DIR, 'franklin-desktop-history.json'), JSON.stringify(conversations), { mode: 0o600 });
|
|
541
|
+
if (isCloudSyncEnabled())
|
|
542
|
+
void cloudSync(conversations).catch(() => { });
|
|
543
|
+
send(ws, id, 'response', { ok: true });
|
|
544
|
+
}
|
|
545
|
+
catch (err) {
|
|
546
|
+
send(ws, id, 'error', { message: err instanceof Error ? err.message : 'history save error' });
|
|
547
|
+
}
|
|
548
|
+
break;
|
|
549
|
+
}
|
|
550
|
+
case 'wallet.swaps': {
|
|
551
|
+
try {
|
|
552
|
+
send(ws, id, 'response', { swaps: readSwaps(100) });
|
|
553
|
+
}
|
|
554
|
+
catch (err) {
|
|
555
|
+
send(ws, id, 'error', { message: err instanceof Error ? err.message : 'swaps error' });
|
|
556
|
+
}
|
|
557
|
+
break;
|
|
558
|
+
}
|
|
559
|
+
case 'wallet.spend': {
|
|
560
|
+
// Real spend, sourced from the x402 settlement ledger (cost_log.jsonl) —
|
|
561
|
+
// the same truth the CLI dashboard uses. Covers BOTH model calls and
|
|
562
|
+
// paid tools (web search, image gen, …), not a token estimate.
|
|
563
|
+
try {
|
|
564
|
+
const rows = loadSdkSettlements();
|
|
565
|
+
const byModel = {};
|
|
566
|
+
let totalUsd = 0;
|
|
567
|
+
for (const r of rows) {
|
|
568
|
+
const key = r.model || r.endpoint || 'unknown';
|
|
569
|
+
const b = byModel[key] ?? { usd: 0, count: 0 };
|
|
570
|
+
b.usd += r.costUsd;
|
|
571
|
+
b.count += 1;
|
|
572
|
+
byModel[key] = b;
|
|
573
|
+
totalUsd += r.costUsd;
|
|
574
|
+
}
|
|
575
|
+
const receipts = [...rows]
|
|
576
|
+
.sort((a, b) => b.ts - a.ts)
|
|
577
|
+
.slice(0, 100)
|
|
578
|
+
.map((r) => ({ ts: r.ts, model: r.model || r.endpoint || 'unknown', usd: r.costUsd }));
|
|
579
|
+
send(ws, id, 'response', { totalUsd, requests: rows.length, byModel, receipts });
|
|
580
|
+
}
|
|
581
|
+
catch (err) {
|
|
582
|
+
send(ws, id, 'error', { message: err instanceof Error ? err.message : 'spend error' });
|
|
583
|
+
}
|
|
584
|
+
break;
|
|
585
|
+
}
|
|
586
|
+
case 'models.list': {
|
|
587
|
+
try {
|
|
588
|
+
const models = await getModelsByCategory('chat');
|
|
589
|
+
const mapped = models.map((m) => {
|
|
590
|
+
const provider = providerLabel(m.id, m.owned_by);
|
|
591
|
+
return {
|
|
592
|
+
id: m.id,
|
|
593
|
+
label: m.name,
|
|
594
|
+
free: m.billing_mode === 'free',
|
|
595
|
+
group: provider,
|
|
596
|
+
provider,
|
|
597
|
+
contextWindow: m.context_window,
|
|
598
|
+
};
|
|
599
|
+
});
|
|
600
|
+
// Group by provider (PROVIDER_ORDER first, then alphabetical); free
|
|
601
|
+
// models surface first within each provider. The picker renders
|
|
602
|
+
// consecutive same-`group` items as one section, so sort accordingly.
|
|
603
|
+
const rank = (g) => { const i = PROVIDER_ORDER.indexOf(g); return i < 0 ? 999 : i; };
|
|
604
|
+
mapped.sort((a, b) => rank(a.group) - rank(b.group) ||
|
|
605
|
+
a.group.localeCompare(b.group) ||
|
|
606
|
+
(a.free === b.free ? 0 : a.free ? -1 : 1) ||
|
|
607
|
+
a.label.localeCompare(b.label));
|
|
608
|
+
send(ws, id, 'response', { models: mapped });
|
|
609
|
+
}
|
|
610
|
+
catch (err) {
|
|
611
|
+
send(ws, id, 'error', { message: err instanceof Error ? err.message : 'models error' });
|
|
612
|
+
}
|
|
613
|
+
break;
|
|
614
|
+
}
|
|
615
|
+
case 'agent.send': {
|
|
616
|
+
const text = String(p.text ?? '').trim();
|
|
617
|
+
if (!text) {
|
|
618
|
+
send(ws, id, 'agent.error', { sessionId: '', message: 'empty input' });
|
|
619
|
+
break;
|
|
620
|
+
}
|
|
621
|
+
activeWs = ws;
|
|
622
|
+
activeTurnId = id;
|
|
623
|
+
stepIds.clear();
|
|
624
|
+
// A non-empty model means "switch the chat model". Media turns send no
|
|
625
|
+
// model (the image/video model is a TOOL parameter baked into the
|
|
626
|
+
// prompt, NOT the chat model — switching the chat model to an image
|
|
627
|
+
// model breaks the turn), so we keep the current chat model for them.
|
|
628
|
+
const desiredModel = p.model ? String(p.model) : null;
|
|
629
|
+
const clientConvId = p.convId ? String(p.convId) : null;
|
|
630
|
+
await ensureSession(desiredModel || userConfig['default-model'] || FREE_DEFAULT_MODEL);
|
|
631
|
+
// Conversation switch → wipe the agent's history so a new/other chat
|
|
632
|
+
// doesn't inherit the previous one's context. Only when we already had a
|
|
633
|
+
// different conversation loaded (not on the very first turn).
|
|
634
|
+
if (clientConvId && currentConvId && clientConvId !== currentConvId) {
|
|
635
|
+
suppressTurns++; // swallow the injected /clear turn (see onEvent)
|
|
636
|
+
pushInput('/clear');
|
|
637
|
+
}
|
|
638
|
+
if (clientConvId)
|
|
639
|
+
currentConvId = clientConvId;
|
|
640
|
+
if (desiredModel && currentModel && desiredModel !== currentModel) {
|
|
641
|
+
suppressTurns++; // swallow the injected /model turn (see onEvent)
|
|
642
|
+
pushInput(`/model ${desiredModel}`);
|
|
643
|
+
currentModel = desiredModel;
|
|
644
|
+
}
|
|
645
|
+
pushInput(text);
|
|
646
|
+
break;
|
|
647
|
+
}
|
|
648
|
+
case 'settings.get':
|
|
649
|
+
send(ws, id, 'response', { costSaver });
|
|
650
|
+
break;
|
|
651
|
+
case 'settings.set': {
|
|
652
|
+
if (typeof p.costSaver === 'boolean') {
|
|
653
|
+
costSaver = p.costSaver;
|
|
654
|
+
if (agentConfig)
|
|
655
|
+
agentConfig.costSaver = costSaver; // live-update the running session
|
|
656
|
+
setConfigValue('cost-saver', costSaver ? 'true' : 'false'); // persist across restarts
|
|
657
|
+
}
|
|
658
|
+
send(ws, id, 'response', { costSaver });
|
|
659
|
+
break;
|
|
660
|
+
}
|
|
661
|
+
case 'agent.cancel':
|
|
662
|
+
if (abortFn)
|
|
663
|
+
abortFn();
|
|
664
|
+
break;
|
|
665
|
+
case 'agent.permissionResponse':
|
|
666
|
+
// permissionMode is 'trust' — nothing to unblock.
|
|
667
|
+
break;
|
|
668
|
+
default:
|
|
669
|
+
send(ws, id, 'error', { message: `Unknown kind: ${kind}` });
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
// ── HTTP + WS ──
|
|
673
|
+
// HTTP: a /file route streams a generated media file (audio/video/image) so
|
|
674
|
+
// the renderer can play it. The path param is confined to media files under
|
|
675
|
+
// the session work dir (plus FRANKLIN_SERVE_FILE_ROOTS extras) — NOT the
|
|
676
|
+
// whole filesystem — and the request must pass the Origin gate. Otherwise a
|
|
677
|
+
// hostile page could fetch wallet/key files off the loopback port.
|
|
678
|
+
const fileRoots = [
|
|
679
|
+
workDir,
|
|
680
|
+
path.join(BLOCKRUN_DIR, 'content'),
|
|
681
|
+
...(process.env.FRANKLIN_SERVE_FILE_ROOTS || '').split(',').map((s) => s.trim()).filter(Boolean),
|
|
682
|
+
].map((r) => { try {
|
|
683
|
+
return fs.realpathSync(r);
|
|
684
|
+
}
|
|
685
|
+
catch {
|
|
686
|
+
return null;
|
|
687
|
+
} }).filter((r) => !!r);
|
|
688
|
+
const MEDIA_MIME = {
|
|
689
|
+
mp3: 'audio/mpeg', wav: 'audio/wav', m4a: 'audio/mp4', ogg: 'audio/ogg', flac: 'audio/flac',
|
|
690
|
+
mp4: 'video/mp4', webm: 'video/webm',
|
|
691
|
+
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif',
|
|
692
|
+
};
|
|
693
|
+
const httpServer = http.createServer((req, res) => {
|
|
694
|
+
try {
|
|
695
|
+
const url = new URL(req.url || '/', 'http://127.0.0.1');
|
|
696
|
+
const origin = req.headers.origin;
|
|
697
|
+
if (url.pathname === '/file') {
|
|
698
|
+
if (!isOriginAllowed(origin) || !tokenOk(url)) {
|
|
699
|
+
res.writeHead(403);
|
|
700
|
+
res.end();
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
const p = url.searchParams.get('path') || '';
|
|
704
|
+
if (!p || !fs.existsSync(p) || !fs.statSync(p).isFile()) {
|
|
705
|
+
res.writeHead(404);
|
|
706
|
+
res.end();
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
// Resolve symlinks before the prefix check so a link can't escape a root.
|
|
710
|
+
const real = fs.realpathSync(p);
|
|
711
|
+
const inRoot = fileRoots.some((root) => real === root || real.startsWith(root + path.sep));
|
|
712
|
+
const ext = real.toLowerCase().split('.').pop() || '';
|
|
713
|
+
const mime = MEDIA_MIME[ext];
|
|
714
|
+
if (!inRoot || !mime) {
|
|
715
|
+
res.writeHead(403);
|
|
716
|
+
res.end();
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
res.writeHead(200, {
|
|
720
|
+
'Content-Type': mime,
|
|
721
|
+
// Reflect the (already vetted) origin instead of `*` so arbitrary
|
|
722
|
+
// sites can't read the bytes cross-origin.
|
|
723
|
+
...(origin && origin !== 'null' ? { 'Access-Control-Allow-Origin': origin } : {}),
|
|
724
|
+
});
|
|
725
|
+
fs.createReadStream(real).pipe(res);
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
catch { /* fall through to 404 */ }
|
|
730
|
+
res.writeHead(404);
|
|
731
|
+
res.end();
|
|
732
|
+
});
|
|
733
|
+
const wss = new WebSocket.Server({
|
|
734
|
+
server: httpServer,
|
|
735
|
+
path: '/agent',
|
|
736
|
+
// Same gate as /file: refuse upgrades from non-allowlisted browser origins
|
|
737
|
+
// (and require the token when one is configured). See isOriginAllowed.
|
|
738
|
+
verifyClient: (info) => {
|
|
739
|
+
const url = new URL(info.req.url || '/', 'http://127.0.0.1');
|
|
740
|
+
const allowed = isOriginAllowed(info.origin || info.req.headers.origin) && tokenOk(url);
|
|
741
|
+
if (!allowed && debug)
|
|
742
|
+
console.log(`[serve] rejected WS upgrade from origin=${info.origin ?? 'n/a'}`);
|
|
743
|
+
return allowed;
|
|
744
|
+
},
|
|
745
|
+
});
|
|
746
|
+
wss.on('connection', (ws) => {
|
|
747
|
+
ws.on('message', (raw) => {
|
|
748
|
+
let msg;
|
|
749
|
+
try {
|
|
750
|
+
msg = JSON.parse(raw.toString());
|
|
751
|
+
}
|
|
752
|
+
catch {
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
handle(ws, msg).catch((err) => {
|
|
756
|
+
send(ws, msg.id, 'error', { message: err instanceof Error ? err.message : String(err) });
|
|
757
|
+
});
|
|
758
|
+
});
|
|
759
|
+
});
|
|
760
|
+
await new Promise((resolve) => {
|
|
761
|
+
httpServer.listen(port, '127.0.0.1', () => {
|
|
762
|
+
// eslint-disable-next-line no-console
|
|
763
|
+
console.log(`Franklin agent server on ws://127.0.0.1:${port}/agent (chain: ${chain}, workdir: ${workDir})`);
|
|
764
|
+
resolve();
|
|
765
|
+
});
|
|
766
|
+
});
|
|
767
|
+
}
|