planche 0.7.20 → 0.8.1
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/app/assets/{AboutDialog-dKk703-Z.js → AboutDialog-3SX5EV22.js} +1 -1
- package/app/assets/AiSettingsDialog-DIhTViGS.js +1 -0
- package/app/assets/ConnectDialog-B9B2pmBd.js +1 -0
- package/app/assets/CopyDatabaseDialog-DV7zQRd2.js +1 -0
- package/app/assets/CopyTableDialog-1A2wKslg.js +1 -0
- package/app/assets/{CreateDatabaseDialog-Dnulabm5.js → CreateDatabaseDialog-CLiJJQgN.js} +1 -1
- package/app/assets/CreateSchemaDialog-DD_28Eub.js +1 -0
- package/app/assets/DataDiffDialog-hIvWJqmh.js +1 -0
- package/app/assets/{DownloadToCSVDialog-jxCkzB3J.js → DownloadToCSVDialog-CLpJ8bDP.js} +2 -2
- package/app/assets/EditSchemaDialog-B8ONt2sC.js +2 -0
- package/app/assets/FindReplaceDialog-H1fQsF0u.js +1 -0
- package/app/assets/{FlushDialog-Cp7W6Ck1.js → FlushDialog-Cu_3tGcK.js} +1 -1
- package/app/assets/GeneralSettingsDialog-xgxhF9Gn.js +1 -0
- package/app/assets/GroupByDialog-BCDcazzJ.js +1 -0
- package/app/assets/LicenseDialog-DQHhxdtR.js +1 -0
- package/app/assets/LinkColumnDialog-V430TeVp.js +1 -0
- package/app/assets/LocalHistoryDialog-BA_7Fo9B.js +1 -0
- package/app/assets/McpSetupDialog-BFoqHq0W.js +1 -0
- package/app/assets/ProcessListDialog-C580troj.js +1 -0
- package/app/assets/QuerySettingsDialog-CxaKHHfo.js +1 -0
- package/app/assets/QuickCommandPalette-DI4_5uY6.js +1 -0
- package/app/assets/RowFormDialog-DXK8CWzh.js +2 -0
- package/app/assets/SafetySettingsDialog-D5yLNWsA.js +1 -0
- package/app/assets/SchemaDiffDialog-DsXAMyhh.js +7 -0
- package/app/assets/{SchemaToHTMLDialog-Da8c-rAe.js → SchemaToHTMLDialog-CDrt2YXj.js} +2 -2
- package/app/assets/StatusDialog-CnIJeQpO.js +1 -0
- package/app/assets/SyncDialog-DXLRjtc3.js +1 -0
- package/app/assets/{TermsDialog-CWw3XSw0.js → TermsDialog-D4K_QWCp.js} +1 -1
- package/app/assets/{UserAddDialog-D-TQNCsa.js → UserAddDialog-DPBPQ90W.js} +1 -1
- package/app/assets/UserManagerDialog-_slF5p1Y.js +1 -0
- package/app/assets/VariablesDialog-IdnBQyHZ.js +1 -0
- package/app/assets/connection-store-D-Lp5EFj.js +1 -0
- package/app/assets/contract-neLPq0_M.js +1 -0
- package/app/assets/database-CfYjCIA2.js +1 -0
- package/app/assets/de-U48bWKTK.js +2 -0
- package/app/assets/{engine-DtZy_Zk0.js → engine-Ja9hkC7I.js} +1 -1
- package/app/assets/error-hints-9kGurk72.js +8 -0
- package/app/assets/fr-B_almVyv.js +2 -0
- package/app/assets/i18n-BQcNT-sS.js +2 -0
- package/app/assets/ja-BS_mc_Gc.js +2 -0
- package/app/assets/ko-DgeKS5Bf.js +2 -0
- package/app/assets/main-BwM1zx0B.js +959 -0
- package/app/assets/main-D2x95iIF.css +2 -0
- package/app/assets/{pro-upsell-lFmxmise.js → pro-upsell-BH-enstO.js} +1 -1
- package/app/assets/pro-usage-D4Iv3hEs.js +1 -0
- package/app/assets/query-execution-_bzhSmJm.js +2 -0
- package/app/assets/{query-store-BR6Saeed.js → query-store-Cf_KUo80.js} +1 -1
- package/app/assets/{select-C3A-RS4G.js → select-CtfiGpCP.js} +1 -1
- package/app/assets/settings-controls-DyukG7E2.js +1 -0
- package/app/assets/settings-store-DzQ3BP5e.js +1 -0
- package/app/assets/table-copy-JytHH7NG.js +1 -0
- package/app/assets/tunneling-C--CCnwy.js +6 -0
- package/app/assets/zh-CoZStBZ6.js +2 -0
- package/app/index.html +14 -16
- package/dist/index.js +799 -45
- package/package.json +3 -1
- package/app/assets/ConnectDialog-DdXULzdj.js +0 -1
- package/app/assets/CopyDatabaseDialog-B7w3Ek74.js +0 -1
- package/app/assets/CopyTableDialog-B0kK-0Bl.js +0 -1
- package/app/assets/CreateSchemaDialog-Lvff6Tdj.js +0 -1
- package/app/assets/DataDiffDialog-DNTu-7-T.js +0 -1
- package/app/assets/EditSchemaDialog-CFrD-8z9.js +0 -2
- package/app/assets/FindReplaceDialog-CyNlKq00.js +0 -1
- package/app/assets/GroupByDialog-CHwsURs9.js +0 -1
- package/app/assets/LicenseDialog-RY0SOUb1.js +0 -1
- package/app/assets/LinkColumnDialog-BD9rpsyg.js +0 -1
- package/app/assets/LocalHistoryDialog-LnF86KJl.js +0 -1
- package/app/assets/ProcessListDialog-Jt-1b88E.js +0 -1
- package/app/assets/QuickCommandPalette-DEFuPaG7.js +0 -1
- package/app/assets/RowFormDialog-mM6FFZQr.js +0 -2
- package/app/assets/SafetySettingsDialog-B3MafT1S.js +0 -1
- package/app/assets/SchemaDiffDialog-BvNzQo5m.js +0 -7
- package/app/assets/StatusDialog-mibIDpmA.js +0 -1
- package/app/assets/SyncDialog-BIDs_FZr.js +0 -1
- package/app/assets/UserManagerDialog-DTxhu0T8.js +0 -1
- package/app/assets/VariablesDialog-CCzXtQQV.js +0 -1
- package/app/assets/connection-store-Ds9Gjt3K.js +0 -1
- package/app/assets/contract-D2InX341.js +0 -1
- package/app/assets/database-eJWHFM-2.js +0 -1
- package/app/assets/de-D9wNOTjH.js +0 -2
- package/app/assets/fr-CMT9T7_Z.js +0 -2
- package/app/assets/i18n-BAv7QdV3.js +0 -2
- package/app/assets/ja-C5Z0_8jZ.js +0 -2
- package/app/assets/ko-BF4ZzLce.js +0 -2
- package/app/assets/license-store-BfuSb6KZ.js +0 -1
- package/app/assets/main-BPskUSLM.js +0 -941
- package/app/assets/main-DmICiyzI.css +0 -2
- package/app/assets/pro-gate-IRkx6bbE.js +0 -1
- package/app/assets/pro-usage-WSD_KOk4.js +0 -1
- package/app/assets/query-execution-C5QxvBZj.js +0 -7
- package/app/assets/settings-store-CSMhaLcw.js +0 -1
- package/app/assets/table-copy-BJBmJ9OO.js +0 -1
- package/app/assets/tunneling-rujGqkfQ.js +0 -1
- package/app/assets/use-engine-CxcEyIko.js +0 -1
- package/app/assets/zh-BpFgSEeR.js +0 -2
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
import { randomUUID as randomUUID3 } from "crypto";
|
|
5
5
|
import { createRequire as createRequire2 } from "module";
|
|
6
|
-
import { spawn } from "child_process";
|
|
6
|
+
import { spawn as spawn2 } from "child_process";
|
|
7
7
|
import { parseArgs } from "util";
|
|
8
8
|
|
|
9
9
|
// src/gateway.ts
|
|
@@ -234,8 +234,8 @@ function toClientConfig(config) {
|
|
|
234
234
|
var SEARCH_PATH_STATEMENT = /^\s*SET\s+(SESSION\s+|LOCAL\s+)?search_path\b/i;
|
|
235
235
|
var quoteIdent = (name) => `"${name.replaceAll('"', '""')}"`;
|
|
236
236
|
var PostgresDriver = class {
|
|
237
|
-
constructor(
|
|
238
|
-
this.client =
|
|
237
|
+
constructor(client2, config) {
|
|
238
|
+
this.client = client2;
|
|
239
239
|
this.config = config;
|
|
240
240
|
this.label = `${config.user}@${config.host}:${config.port}/${config.database}`;
|
|
241
241
|
}
|
|
@@ -346,17 +346,17 @@ var PostgresDriver = class {
|
|
|
346
346
|
};
|
|
347
347
|
var postgresDriverFactory = {
|
|
348
348
|
async open(config) {
|
|
349
|
-
const
|
|
350
|
-
await
|
|
351
|
-
|
|
352
|
-
const driver = new PostgresDriver(
|
|
349
|
+
const client2 = new pg.Client(toClientConfig(config));
|
|
350
|
+
await client2.connect();
|
|
351
|
+
client2.on("error", (err) => console.error("[postgres] Connection error:", err.message));
|
|
352
|
+
const driver = new PostgresDriver(client2, config);
|
|
353
353
|
await driver.loadTypeNames();
|
|
354
354
|
return driver;
|
|
355
355
|
},
|
|
356
356
|
async test(config) {
|
|
357
|
-
const
|
|
358
|
-
await
|
|
359
|
-
await
|
|
357
|
+
const client2 = new pg.Client(toClientConfig(config));
|
|
358
|
+
await client2.connect();
|
|
359
|
+
await client2.end();
|
|
360
360
|
},
|
|
361
361
|
parseConfig(body) {
|
|
362
362
|
const host = typeof body.host === "string" ? body.host.trim() : "";
|
|
@@ -653,8 +653,8 @@ var MssqlDriver = class {
|
|
|
653
653
|
* decide whether it is editable and address rows by primary key.
|
|
654
654
|
*/
|
|
655
655
|
async describe(text) {
|
|
656
|
-
const
|
|
657
|
-
if (
|
|
656
|
+
const cached4 = this.sources.get(text);
|
|
657
|
+
if (cached4 !== void 0) return cached4;
|
|
658
658
|
let result = null;
|
|
659
659
|
try {
|
|
660
660
|
const req = this.pool.request();
|
|
@@ -811,8 +811,8 @@ var OracleDriver = class {
|
|
|
811
811
|
}
|
|
812
812
|
async tableColumns(schema, table) {
|
|
813
813
|
const key = `${schema}.${table}`;
|
|
814
|
-
const
|
|
815
|
-
if (
|
|
814
|
+
const cached4 = this.columns.get(key);
|
|
815
|
+
if (cached4 !== void 0) return cached4;
|
|
816
816
|
let out = null;
|
|
817
817
|
try {
|
|
818
818
|
const res = await this.conn.execute(
|
|
@@ -1065,8 +1065,8 @@ var D1Driver = class {
|
|
|
1065
1065
|
return result;
|
|
1066
1066
|
}
|
|
1067
1067
|
async tableColumns(table) {
|
|
1068
|
-
const
|
|
1069
|
-
if (
|
|
1068
|
+
const cached4 = this.columns.get(table);
|
|
1069
|
+
if (cached4 !== void 0) return cached4;
|
|
1070
1070
|
let out = null;
|
|
1071
1071
|
try {
|
|
1072
1072
|
const info = await this.raw(`PRAGMA table_info("${table.replaceAll('"', '""')}")`);
|
|
@@ -1504,8 +1504,8 @@ var DoDriver = class {
|
|
|
1504
1504
|
* like one and as a name otherwise.
|
|
1505
1505
|
*/
|
|
1506
1506
|
targetFor(database) {
|
|
1507
|
-
const
|
|
1508
|
-
if (
|
|
1507
|
+
const cached4 = this.targets.get(database);
|
|
1508
|
+
if (cached4) return cached4;
|
|
1509
1509
|
const slash = database.indexOf("/");
|
|
1510
1510
|
if (slash <= 0 || slash === database.length - 1) {
|
|
1511
1511
|
throw new DoError(
|
|
@@ -1519,8 +1519,8 @@ var DoDriver = class {
|
|
|
1519
1519
|
}
|
|
1520
1520
|
async tableColumns(database, target, table) {
|
|
1521
1521
|
const key = `${database} ${table}`;
|
|
1522
|
-
const
|
|
1523
|
-
if (
|
|
1522
|
+
const cached4 = this.columns.get(key);
|
|
1523
|
+
if (cached4 !== void 0) return cached4;
|
|
1524
1524
|
let out = null;
|
|
1525
1525
|
try {
|
|
1526
1526
|
const info = await this.transport.run(target, `PRAGMA table_info("${table.replaceAll('"', '""')}")`);
|
|
@@ -1975,7 +1975,7 @@ function describeSshError(err, ssh) {
|
|
|
1975
1975
|
}
|
|
1976
1976
|
async function openSshTunnel(requested, target) {
|
|
1977
1977
|
const ssh = await resolveConfig(requested);
|
|
1978
|
-
const
|
|
1978
|
+
const client2 = new Client();
|
|
1979
1979
|
const connectConfig = toConnectConfig(ssh);
|
|
1980
1980
|
let verifyError;
|
|
1981
1981
|
connectConfig.hostVerifier = (key, verified) => {
|
|
@@ -2005,22 +2005,22 @@ async function openSshTunnel(requested, target) {
|
|
|
2005
2005
|
});
|
|
2006
2006
|
};
|
|
2007
2007
|
await new Promise((resolve5, reject) => {
|
|
2008
|
-
|
|
2009
|
-
|
|
2008
|
+
client2.once("ready", resolve5);
|
|
2009
|
+
client2.once("error", (err) => reject(verifyError ?? describeSshError(err, ssh)));
|
|
2010
2010
|
try {
|
|
2011
|
-
|
|
2011
|
+
client2.connect(connectConfig);
|
|
2012
2012
|
} catch (err) {
|
|
2013
2013
|
reject(describeSshError(err, ssh));
|
|
2014
2014
|
}
|
|
2015
2015
|
});
|
|
2016
|
-
|
|
2016
|
+
client2.on("error", (err) => console.error(`[ssh] ${ssh.host}:${ssh.port}: ${err.message}`));
|
|
2017
2017
|
const sockets = /* @__PURE__ */ new Set();
|
|
2018
2018
|
let forwardError;
|
|
2019
2019
|
const server = createServer((socket) => {
|
|
2020
2020
|
sockets.add(socket);
|
|
2021
2021
|
socket.once("close", () => sockets.delete(socket));
|
|
2022
2022
|
socket.on("error", () => socket.destroy());
|
|
2023
|
-
|
|
2023
|
+
client2.forwardOut(socket.localAddress ?? "127.0.0.1", socket.localPort ?? 0, target.host, target.port, (err, stream) => {
|
|
2024
2024
|
if (err) {
|
|
2025
2025
|
const local = /^(localhost|127\.0\.0\.1|::1)$/i.test(target.host);
|
|
2026
2026
|
const hint = local ? `"${target.host}" is resolved on the jump host itself; use the database's real address (e.g. the RDS endpoint) as the database host` : "is the database reachable from the jump host, and does it allow TCP forwarding?";
|
|
@@ -2039,7 +2039,7 @@ async function openSshTunnel(requested, target) {
|
|
|
2039
2039
|
server.once("error", reject);
|
|
2040
2040
|
server.listen(0, "127.0.0.1", () => resolve5());
|
|
2041
2041
|
}).catch((err) => {
|
|
2042
|
-
|
|
2042
|
+
client2.end();
|
|
2043
2043
|
throw new TunnelError(`Could not open local tunnel port: ${err.message}`, 400);
|
|
2044
2044
|
});
|
|
2045
2045
|
const { port } = server.address();
|
|
@@ -2049,11 +2049,11 @@ async function openSshTunnel(requested, target) {
|
|
|
2049
2049
|
closed = true;
|
|
2050
2050
|
for (const s of sockets) s.destroy();
|
|
2051
2051
|
server.close(() => {
|
|
2052
|
-
|
|
2052
|
+
client2.end();
|
|
2053
2053
|
resolve5();
|
|
2054
2054
|
});
|
|
2055
2055
|
});
|
|
2056
|
-
|
|
2056
|
+
client2.once("close", () => void close());
|
|
2057
2057
|
return {
|
|
2058
2058
|
host: "127.0.0.1",
|
|
2059
2059
|
port,
|
|
@@ -2076,11 +2076,11 @@ var PRO_FEATURES = [
|
|
|
2076
2076
|
"compare_schemas",
|
|
2077
2077
|
"compare_data",
|
|
2078
2078
|
"copy_table",
|
|
2079
|
-
"dry_run",
|
|
2080
2079
|
"federated",
|
|
2081
2080
|
"scratchpad",
|
|
2082
2081
|
"column_link",
|
|
2083
|
-
"sync"
|
|
2082
|
+
"sync",
|
|
2083
|
+
"ai_explain"
|
|
2084
2084
|
];
|
|
2085
2085
|
|
|
2086
2086
|
// src/usage.ts
|
|
@@ -2203,12 +2203,12 @@ function parseDriverConfig(body) {
|
|
|
2203
2203
|
const ssh = parseSshConfig(body.ssh);
|
|
2204
2204
|
return ssh ? { ...config, ssh } : config;
|
|
2205
2205
|
}
|
|
2206
|
-
async function withTunnel(config,
|
|
2207
|
-
if (isDirectDriver(config) || !config.ssh) return
|
|
2206
|
+
async function withTunnel(config, run2) {
|
|
2207
|
+
if (isDirectDriver(config) || !config.ssh) return run2(config, void 0);
|
|
2208
2208
|
const tunnel = await openSshTunnel(config.ssh, { host: config.host, port: config.port });
|
|
2209
2209
|
recordUsage("ssh_tunnel");
|
|
2210
2210
|
try {
|
|
2211
|
-
return await
|
|
2211
|
+
return await run2({ ...config, host: tunnel.host, port: tunnel.port, ssh: void 0 }, tunnel);
|
|
2212
2212
|
} catch (err) {
|
|
2213
2213
|
await tunnel.close();
|
|
2214
2214
|
throw tunnel.forwardError ? new TunnelError(tunnel.forwardError, 400) : err;
|
|
@@ -2330,7 +2330,7 @@ function describeProfile(entry) {
|
|
|
2330
2330
|
|
|
2331
2331
|
// src/index.ts
|
|
2332
2332
|
import { existsSync as existsSync4 } from "fs";
|
|
2333
|
-
import { join as
|
|
2333
|
+
import { join as join11, resolve as resolve4 } from "path";
|
|
2334
2334
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
2335
2335
|
|
|
2336
2336
|
// src/install.ts
|
|
@@ -2550,10 +2550,610 @@ async function updateNotice(current) {
|
|
|
2550
2550
|
return isNewer(latest, current) ? `planche ${latest} is available (you have ${current}) \u2014 update with: planche upgrade` : null;
|
|
2551
2551
|
}
|
|
2552
2552
|
|
|
2553
|
+
// src/ai-cli.ts
|
|
2554
|
+
import { execFile as execFileCb2, spawn } from "child_process";
|
|
2555
|
+
import { mkdtemp, readFile as readFile5, rm, writeFile as writeFile3 } from "fs/promises";
|
|
2556
|
+
import { tmpdir } from "os";
|
|
2557
|
+
import { join as join8 } from "path";
|
|
2558
|
+
import { promisify as promisify2 } from "util";
|
|
2559
|
+
|
|
2560
|
+
// src/ai-config.ts
|
|
2561
|
+
import { chmod, mkdir as mkdir3, readFile as readFile4, rename as rename2, unlink, writeFile as writeFile2 } from "fs/promises";
|
|
2562
|
+
import { homedir as homedir7 } from "os";
|
|
2563
|
+
import { join as join7 } from "path";
|
|
2564
|
+
|
|
2565
|
+
// ../shared/ai.ts
|
|
2566
|
+
var AI_DEFAULT_MODEL = {
|
|
2567
|
+
// Anthropic's most capable general model. Explaining a failure is the paid
|
|
2568
|
+
// half of the product, so the default is not chosen to be cheap.
|
|
2569
|
+
anthropic: "claude-opus-5",
|
|
2570
|
+
// Same API, same models; only where the credential comes from differs.
|
|
2571
|
+
"anthropic-profile": "claude-opus-5",
|
|
2572
|
+
openai: "gpt-4o",
|
|
2573
|
+
gemini: "gemini-2.5-flash",
|
|
2574
|
+
// Not a model but the command to run: the CLI decides the model itself.
|
|
2575
|
+
cli: "claude"
|
|
2576
|
+
};
|
|
2577
|
+
|
|
2578
|
+
// src/ai-anthropic.ts
|
|
2579
|
+
import { execFile as execFileCb } from "child_process";
|
|
2580
|
+
import { access } from "fs/promises";
|
|
2581
|
+
import { homedir as homedir6 } from "os";
|
|
2582
|
+
import { join as join6 } from "path";
|
|
2583
|
+
import { promisify } from "util";
|
|
2584
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
2585
|
+
function toMessages(turns) {
|
|
2586
|
+
const out = [];
|
|
2587
|
+
for (const turn of turns) {
|
|
2588
|
+
if (turn.role === "user") {
|
|
2589
|
+
out.push({ role: "user", content: turn.content });
|
|
2590
|
+
continue;
|
|
2591
|
+
}
|
|
2592
|
+
if (turn.role === "assistant") {
|
|
2593
|
+
const blocks = [];
|
|
2594
|
+
if (turn.content.trim()) blocks.push({ type: "text", text: turn.content });
|
|
2595
|
+
for (const call of turn.toolCalls ?? []) {
|
|
2596
|
+
blocks.push({ type: "tool_use", id: call.id, name: call.name, input: call.input });
|
|
2597
|
+
}
|
|
2598
|
+
if (blocks.length > 0) out.push({ role: "assistant", content: blocks });
|
|
2599
|
+
continue;
|
|
2600
|
+
}
|
|
2601
|
+
const result = {
|
|
2602
|
+
type: "tool_result",
|
|
2603
|
+
tool_use_id: turn.toolCallId,
|
|
2604
|
+
content: turn.content,
|
|
2605
|
+
...turn.isError ? { is_error: true } : {}
|
|
2606
|
+
};
|
|
2607
|
+
const last = out[out.length - 1];
|
|
2608
|
+
if (last && last.role === "user" && Array.isArray(last.content)) {
|
|
2609
|
+
last.content.push(result);
|
|
2610
|
+
} else {
|
|
2611
|
+
out.push({ role: "user", content: [result] });
|
|
2612
|
+
}
|
|
2613
|
+
}
|
|
2614
|
+
return out;
|
|
2615
|
+
}
|
|
2616
|
+
var execFile = promisify(execFileCb);
|
|
2617
|
+
async function anthropicProfileAvailable() {
|
|
2618
|
+
try {
|
|
2619
|
+
await execFile("ant", ["auth", "status"], { timeout: 1e4 });
|
|
2620
|
+
return true;
|
|
2621
|
+
} catch {
|
|
2622
|
+
try {
|
|
2623
|
+
await access(join6(homedir6(), ".config", "anthropic"));
|
|
2624
|
+
return true;
|
|
2625
|
+
} catch {
|
|
2626
|
+
return false;
|
|
2627
|
+
}
|
|
2628
|
+
}
|
|
2629
|
+
}
|
|
2630
|
+
async function runAnthropic(config, req) {
|
|
2631
|
+
const client2 = new Anthropic({
|
|
2632
|
+
...config.key ? { apiKey: config.key } : {},
|
|
2633
|
+
...config.baseUrl ? { baseURL: config.baseUrl } : {}
|
|
2634
|
+
});
|
|
2635
|
+
const tools2 = (req.tools ?? []).map((tool) => ({
|
|
2636
|
+
name: tool.name,
|
|
2637
|
+
description: tool.description,
|
|
2638
|
+
input_schema: tool.inputSchema
|
|
2639
|
+
}));
|
|
2640
|
+
try {
|
|
2641
|
+
const response = await client2.messages.create({
|
|
2642
|
+
model: config.model,
|
|
2643
|
+
max_tokens: MAX_TOKENS,
|
|
2644
|
+
system: req.system,
|
|
2645
|
+
messages: toMessages(turnsOf(req)),
|
|
2646
|
+
...tools2.length > 0 ? { tools: tools2 } : {}
|
|
2647
|
+
});
|
|
2648
|
+
const text = response.content.filter((block) => block.type === "text").map((block) => block.text).join("");
|
|
2649
|
+
const toolCalls = response.content.filter((block) => block.type === "tool_use").map((block) => ({ id: block.id, name: block.name, input: block.input ?? {} }));
|
|
2650
|
+
if (!text && toolCalls.length === 0) throw new AiError(502, "The model returned nothing.");
|
|
2651
|
+
return {
|
|
2652
|
+
text,
|
|
2653
|
+
toolCalls,
|
|
2654
|
+
stop: response.stop_reason === "tool_use" ? "tool_use" : response.stop_reason === "max_tokens" ? "length" : "end"
|
|
2655
|
+
};
|
|
2656
|
+
} catch (err) {
|
|
2657
|
+
if (err instanceof AiError) throw err;
|
|
2658
|
+
const noCredential = 'This machine has no Anthropic credential the SDK could use. Run "ant auth login", or set a key instead.';
|
|
2659
|
+
if (err instanceof Anthropic.AuthenticationError) {
|
|
2660
|
+
throw new AiError(401, config.key ? "The AI key was rejected." : noCredential);
|
|
2661
|
+
}
|
|
2662
|
+
if (!config.key && err instanceof Error && /resolve authentication/i.test(err.message)) {
|
|
2663
|
+
throw new AiError(401, noCredential);
|
|
2664
|
+
}
|
|
2665
|
+
if (err instanceof Anthropic.RateLimitError) throw new AiError(429, "The provider is rate limiting this key.");
|
|
2666
|
+
if (err instanceof Anthropic.BadRequestError) throw new AiError(400, err.message);
|
|
2667
|
+
if (err instanceof Anthropic.APIError) throw new AiError(502, `The provider answered ${err.status}: ${err.message}`);
|
|
2668
|
+
throw new AiError(502, err instanceof Error ? err.message : String(err));
|
|
2669
|
+
}
|
|
2670
|
+
}
|
|
2671
|
+
|
|
2672
|
+
// src/ai-config.ts
|
|
2673
|
+
var PLANCHE_DIR4 = join7(homedir7(), ".planche");
|
|
2674
|
+
var AI_FILE = join7(PLANCHE_DIR4, "ai.json");
|
|
2675
|
+
function envConfig() {
|
|
2676
|
+
const key = process.env.PLANCHE_AI_KEY?.trim() || process.env.ANTHROPIC_API_KEY?.trim() || process.env.OPENAI_API_KEY?.trim() || process.env.GEMINI_API_KEY?.trim();
|
|
2677
|
+
if (!key) return null;
|
|
2678
|
+
const named = process.env.PLANCHE_AI_PROVIDER?.trim();
|
|
2679
|
+
const provider = named === "openai" || named === "anthropic" || named === "gemini" || named === "cli" || named === "anthropic-profile" ? named : process.env.GEMINI_API_KEY?.trim() ? "gemini" : process.env.OPENAI_API_KEY?.trim() && !process.env.ANTHROPIC_API_KEY?.trim() ? "openai" : "anthropic";
|
|
2680
|
+
return {
|
|
2681
|
+
provider,
|
|
2682
|
+
model: process.env.PLANCHE_AI_MODEL?.trim() || AI_DEFAULT_MODEL[provider],
|
|
2683
|
+
baseUrl: process.env.PLANCHE_AI_BASE_URL?.trim() || void 0,
|
|
2684
|
+
key
|
|
2685
|
+
};
|
|
2686
|
+
}
|
|
2687
|
+
var session = null;
|
|
2688
|
+
var cached2;
|
|
2689
|
+
async function fileConfig() {
|
|
2690
|
+
if (cached2 !== void 0) return cached2;
|
|
2691
|
+
try {
|
|
2692
|
+
const raw = JSON.parse(await readFile4(AI_FILE, "utf8"));
|
|
2693
|
+
const provider = raw.provider === "openai" || raw.provider === "gemini" ? raw.provider : "anthropic";
|
|
2694
|
+
cached2 = typeof raw.key === "string" && raw.key ? {
|
|
2695
|
+
provider,
|
|
2696
|
+
model: typeof raw.model === "string" && raw.model ? raw.model : AI_DEFAULT_MODEL[provider],
|
|
2697
|
+
baseUrl: typeof raw.baseUrl === "string" && raw.baseUrl ? raw.baseUrl : void 0,
|
|
2698
|
+
key: raw.key
|
|
2699
|
+
} : null;
|
|
2700
|
+
} catch {
|
|
2701
|
+
cached2 = null;
|
|
2702
|
+
}
|
|
2703
|
+
return cached2;
|
|
2704
|
+
}
|
|
2705
|
+
async function aiConfig() {
|
|
2706
|
+
return envConfig() ?? session ?? await fileConfig();
|
|
2707
|
+
}
|
|
2708
|
+
async function aiConfigView() {
|
|
2709
|
+
const env = envConfig();
|
|
2710
|
+
const file = env ? null : await fileConfig();
|
|
2711
|
+
const config = env ?? session ?? file;
|
|
2712
|
+
const storage = env ? "environment" : session ? "session" : file ? "file" : "none";
|
|
2713
|
+
return {
|
|
2714
|
+
configured: config !== null,
|
|
2715
|
+
provider: config?.provider ?? "anthropic",
|
|
2716
|
+
model: config?.model ?? AI_DEFAULT_MODEL.anthropic,
|
|
2717
|
+
baseUrl: config?.baseUrl,
|
|
2718
|
+
fromEnvironment: env !== null,
|
|
2719
|
+
storage,
|
|
2720
|
+
path: AI_FILE,
|
|
2721
|
+
clis: await installedClis(),
|
|
2722
|
+
anthropicProfile: await anthropicProfileAvailable()
|
|
2723
|
+
};
|
|
2724
|
+
}
|
|
2725
|
+
function defaultModelFor(provider) {
|
|
2726
|
+
return AI_DEFAULT_MODEL[provider];
|
|
2727
|
+
}
|
|
2728
|
+
async function saveAiConfig(config, remember) {
|
|
2729
|
+
if (!remember) {
|
|
2730
|
+
session = config;
|
|
2731
|
+
await forgetStoredConfig();
|
|
2732
|
+
return false;
|
|
2733
|
+
}
|
|
2734
|
+
session = null;
|
|
2735
|
+
await mkdir3(PLANCHE_DIR4, { recursive: true });
|
|
2736
|
+
const temp = `${AI_FILE}.${process.pid}.tmp`;
|
|
2737
|
+
await writeFile2(temp, JSON.stringify(config, null, 2), { mode: 384 });
|
|
2738
|
+
await chmod(temp, 384);
|
|
2739
|
+
await rename2(temp, AI_FILE);
|
|
2740
|
+
cached2 = config;
|
|
2741
|
+
return true;
|
|
2742
|
+
}
|
|
2743
|
+
async function forgetStoredConfig() {
|
|
2744
|
+
cached2 = null;
|
|
2745
|
+
try {
|
|
2746
|
+
await unlink(AI_FILE);
|
|
2747
|
+
} catch {
|
|
2748
|
+
}
|
|
2749
|
+
}
|
|
2750
|
+
async function forgetAiConfig() {
|
|
2751
|
+
session = null;
|
|
2752
|
+
await forgetStoredConfig();
|
|
2753
|
+
}
|
|
2754
|
+
var aiConfigPath = AI_FILE;
|
|
2755
|
+
|
|
2756
|
+
// src/ai-gemini.ts
|
|
2757
|
+
var DEFAULT_BASE = "https://generativelanguage.googleapis.com/v1beta";
|
|
2758
|
+
function toContents(turns) {
|
|
2759
|
+
const out = [];
|
|
2760
|
+
for (const turn of turns) {
|
|
2761
|
+
if (turn.role === "user") {
|
|
2762
|
+
out.push({ role: "user", parts: [{ text: turn.content }] });
|
|
2763
|
+
} else if (turn.role === "assistant") {
|
|
2764
|
+
const parts = [];
|
|
2765
|
+
if (turn.content.trim()) parts.push({ text: turn.content });
|
|
2766
|
+
for (const call of turn.toolCalls ?? []) parts.push({ functionCall: { name: call.name, args: call.input } });
|
|
2767
|
+
if (parts.length > 0) out.push({ role: "model", parts });
|
|
2768
|
+
} else {
|
|
2769
|
+
const part = {
|
|
2770
|
+
functionResponse: { name: turn.toolCallId, response: { result: turn.content, ...turn.isError ? { error: true } : {} } }
|
|
2771
|
+
};
|
|
2772
|
+
const last = out[out.length - 1];
|
|
2773
|
+
if (last && last.role === "user" && last.parts.every((p) => p.functionResponse)) last.parts.push(part);
|
|
2774
|
+
else out.push({ role: "user", parts: [part] });
|
|
2775
|
+
}
|
|
2776
|
+
}
|
|
2777
|
+
return out;
|
|
2778
|
+
}
|
|
2779
|
+
async function runGemini(config, req) {
|
|
2780
|
+
const base = (config.baseUrl || DEFAULT_BASE).replace(/\/+$/, "");
|
|
2781
|
+
const url = `${base}/models/${encodeURIComponent(config.model)}:generateContent?key=${encodeURIComponent(config.key)}`;
|
|
2782
|
+
const tools2 = req.tools ?? [];
|
|
2783
|
+
let response;
|
|
2784
|
+
try {
|
|
2785
|
+
response = await fetch(url, {
|
|
2786
|
+
method: "POST",
|
|
2787
|
+
headers: { "content-type": "application/json" },
|
|
2788
|
+
body: JSON.stringify({
|
|
2789
|
+
systemInstruction: { parts: [{ text: req.system }] },
|
|
2790
|
+
contents: toContents(turnsOf(req)),
|
|
2791
|
+
...tools2.length > 0 ? {
|
|
2792
|
+
tools: [
|
|
2793
|
+
{
|
|
2794
|
+
functionDeclarations: tools2.map((tool) => ({
|
|
2795
|
+
name: tool.name,
|
|
2796
|
+
description: tool.description,
|
|
2797
|
+
parameters: tool.inputSchema
|
|
2798
|
+
}))
|
|
2799
|
+
}
|
|
2800
|
+
]
|
|
2801
|
+
} : {},
|
|
2802
|
+
generationConfig: {
|
|
2803
|
+
// A JSON mime type and function calling cannot both be asked for.
|
|
2804
|
+
...tools2.length > 0 ? {} : { responseMimeType: "application/json" },
|
|
2805
|
+
maxOutputTokens: MAX_TOKENS
|
|
2806
|
+
}
|
|
2807
|
+
})
|
|
2808
|
+
});
|
|
2809
|
+
} catch (err) {
|
|
2810
|
+
throw new AiError(502, err instanceof Error ? err.message : String(err));
|
|
2811
|
+
}
|
|
2812
|
+
const data = await response.json().catch(() => null);
|
|
2813
|
+
if (!response.ok) {
|
|
2814
|
+
const message = data?.error?.message ?? `${response.status} ${response.statusText}`;
|
|
2815
|
+
if (response.status === 400 || response.status === 401 || response.status === 403) {
|
|
2816
|
+
throw new AiError(401, `The AI key was rejected: ${message}`);
|
|
2817
|
+
}
|
|
2818
|
+
if (response.status === 429) throw new AiError(429, "The provider is rate limiting this key.");
|
|
2819
|
+
throw new AiError(502, `The provider answered ${response.status}: ${message}`);
|
|
2820
|
+
}
|
|
2821
|
+
const candidate = data?.candidates?.[0];
|
|
2822
|
+
const parts = candidate?.content?.parts ?? [];
|
|
2823
|
+
const text = parts.map((p) => p.text ?? "").join("");
|
|
2824
|
+
const toolCalls = parts.filter((p) => typeof p.functionCall?.name === "string").map((p) => ({ id: p.functionCall.name, name: p.functionCall.name, input: p.functionCall.args ?? {} }));
|
|
2825
|
+
if (!text && toolCalls.length === 0) throw new AiError(502, "The model returned nothing.");
|
|
2826
|
+
return {
|
|
2827
|
+
text,
|
|
2828
|
+
toolCalls,
|
|
2829
|
+
stop: toolCalls.length > 0 ? "tool_use" : candidate?.finishReason === "MAX_TOKENS" ? "length" : "end"
|
|
2830
|
+
};
|
|
2831
|
+
}
|
|
2832
|
+
|
|
2833
|
+
// src/ai-openai.ts
|
|
2834
|
+
import OpenAI from "openai";
|
|
2835
|
+
function toMessages2(system, turns) {
|
|
2836
|
+
const out = [{ role: "system", content: system }];
|
|
2837
|
+
for (const turn of turns) {
|
|
2838
|
+
if (turn.role === "user") {
|
|
2839
|
+
out.push({ role: "user", content: turn.content });
|
|
2840
|
+
} else if (turn.role === "assistant") {
|
|
2841
|
+
const calls = turn.toolCalls ?? [];
|
|
2842
|
+
out.push({
|
|
2843
|
+
role: "assistant",
|
|
2844
|
+
content: turn.content || null,
|
|
2845
|
+
...calls.length > 0 ? {
|
|
2846
|
+
tool_calls: calls.map((call) => ({
|
|
2847
|
+
id: call.id,
|
|
2848
|
+
type: "function",
|
|
2849
|
+
function: { name: call.name, arguments: JSON.stringify(call.input) }
|
|
2850
|
+
}))
|
|
2851
|
+
} : {}
|
|
2852
|
+
});
|
|
2853
|
+
} else {
|
|
2854
|
+
out.push({ role: "tool", tool_call_id: turn.toolCallId, content: turn.content });
|
|
2855
|
+
}
|
|
2856
|
+
}
|
|
2857
|
+
return out;
|
|
2858
|
+
}
|
|
2859
|
+
function parseArguments(raw) {
|
|
2860
|
+
try {
|
|
2861
|
+
const parsed = JSON.parse(raw || "{}");
|
|
2862
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
2863
|
+
} catch {
|
|
2864
|
+
return {};
|
|
2865
|
+
}
|
|
2866
|
+
}
|
|
2867
|
+
async function runOpenAI(config, req) {
|
|
2868
|
+
const client2 = new OpenAI({ apiKey: config.key, ...config.baseUrl ? { baseURL: config.baseUrl } : {} });
|
|
2869
|
+
const tools2 = (req.tools ?? []).map((tool) => ({
|
|
2870
|
+
type: "function",
|
|
2871
|
+
function: { name: tool.name, description: tool.description, parameters: tool.inputSchema }
|
|
2872
|
+
}));
|
|
2873
|
+
try {
|
|
2874
|
+
const response = await client2.chat.completions.create({
|
|
2875
|
+
model: config.model,
|
|
2876
|
+
max_completion_tokens: MAX_TOKENS,
|
|
2877
|
+
// JSON mode and tools are mutually exclusive here: forcing a JSON object
|
|
2878
|
+
// leaves the model no way to answer with a call. The one-shot prompts
|
|
2879
|
+
// ask for JSON, a tool run does not.
|
|
2880
|
+
...tools2.length > 0 ? { tools: tools2 } : { response_format: { type: "json_object" } },
|
|
2881
|
+
messages: toMessages2(req.system, turnsOf(req))
|
|
2882
|
+
});
|
|
2883
|
+
const choice = response.choices[0];
|
|
2884
|
+
const text = choice?.message?.content ?? "";
|
|
2885
|
+
const toolCalls = (choice?.message?.tool_calls ?? []).filter((call) => call.type === "function").map((call) => ({ id: call.id, name: call.function.name, input: parseArguments(call.function.arguments) }));
|
|
2886
|
+
if (!text && toolCalls.length === 0) throw new AiError(502, "The model returned nothing.");
|
|
2887
|
+
return {
|
|
2888
|
+
text,
|
|
2889
|
+
toolCalls,
|
|
2890
|
+
stop: choice?.finish_reason === "tool_calls" ? "tool_use" : choice?.finish_reason === "length" ? "length" : "end"
|
|
2891
|
+
};
|
|
2892
|
+
} catch (err) {
|
|
2893
|
+
if (err instanceof AiError) throw err;
|
|
2894
|
+
if (err instanceof OpenAI.APIError) {
|
|
2895
|
+
const status = err.status === 401 || err.status === 429 || err.status === 400 ? err.status : 502;
|
|
2896
|
+
throw new AiError(status, err.status === 401 ? "The AI key was rejected." : err.message);
|
|
2897
|
+
}
|
|
2898
|
+
throw new AiError(502, err instanceof Error ? err.message : String(err));
|
|
2899
|
+
}
|
|
2900
|
+
}
|
|
2901
|
+
|
|
2902
|
+
// src/ai-run.ts
|
|
2903
|
+
var AiError = class extends Error {
|
|
2904
|
+
constructor(status, message) {
|
|
2905
|
+
super(message);
|
|
2906
|
+
this.status = status;
|
|
2907
|
+
this.name = "AiError";
|
|
2908
|
+
}
|
|
2909
|
+
status;
|
|
2910
|
+
};
|
|
2911
|
+
var MAX_TOKENS = 8e3;
|
|
2912
|
+
function turnsOf(req) {
|
|
2913
|
+
if (req.messages && req.messages.length > 0) return req.messages;
|
|
2914
|
+
return [{ role: "user", content: req.user ?? "" }];
|
|
2915
|
+
}
|
|
2916
|
+
async function runPrompt(req) {
|
|
2917
|
+
const config = await aiConfig();
|
|
2918
|
+
if (!config) throw new AiError(501, "No AI key is configured on this agent.");
|
|
2919
|
+
const model = req.model?.trim() || config.model;
|
|
2920
|
+
const run2 = config.provider === "cli" ? runCli : config.provider === "openai" ? runOpenAI : config.provider === "gemini" ? runGemini : runAnthropic;
|
|
2921
|
+
const result = await run2({ ...config, model }, req);
|
|
2922
|
+
return {
|
|
2923
|
+
success: true,
|
|
2924
|
+
text: result.text,
|
|
2925
|
+
model,
|
|
2926
|
+
...result.toolCalls.length > 0 ? { toolCalls: result.toolCalls } : {},
|
|
2927
|
+
stop: result.stop
|
|
2928
|
+
};
|
|
2929
|
+
}
|
|
2930
|
+
|
|
2931
|
+
// src/ai-cli.ts
|
|
2932
|
+
var execFile2 = promisify2(execFileCb2);
|
|
2933
|
+
var CLI_COMMANDS = ["claude", "codex"];
|
|
2934
|
+
var TIMEOUT_MS = 18e4;
|
|
2935
|
+
var mcp = null;
|
|
2936
|
+
function configureCliBridge(url, token) {
|
|
2937
|
+
mcp = { url, token };
|
|
2938
|
+
}
|
|
2939
|
+
function mcpUrl() {
|
|
2940
|
+
return mcp?.url ?? null;
|
|
2941
|
+
}
|
|
2942
|
+
function isCliCommand(name) {
|
|
2943
|
+
return CLI_COMMANDS.includes(name);
|
|
2944
|
+
}
|
|
2945
|
+
async function installedClis() {
|
|
2946
|
+
const found = [];
|
|
2947
|
+
for (const name of CLI_COMMANDS) {
|
|
2948
|
+
try {
|
|
2949
|
+
await execFile2(name, ["--version"], { timeout: 1e4 });
|
|
2950
|
+
found.push(name);
|
|
2951
|
+
} catch {
|
|
2952
|
+
}
|
|
2953
|
+
}
|
|
2954
|
+
return found;
|
|
2955
|
+
}
|
|
2956
|
+
function run(command, args, prompt) {
|
|
2957
|
+
return new Promise((resolve5, reject) => {
|
|
2958
|
+
const child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"] });
|
|
2959
|
+
let out = "";
|
|
2960
|
+
let err = "";
|
|
2961
|
+
const timer2 = setTimeout(() => {
|
|
2962
|
+
child.kill("SIGKILL");
|
|
2963
|
+
reject(new AiError(504, `${command} did not answer within ${TIMEOUT_MS / 1e3}s.`));
|
|
2964
|
+
}, TIMEOUT_MS);
|
|
2965
|
+
child.stdout.on("data", (d) => out += d);
|
|
2966
|
+
child.stderr.on("data", (d) => err += d);
|
|
2967
|
+
child.on("error", (e) => {
|
|
2968
|
+
clearTimeout(timer2);
|
|
2969
|
+
reject(new AiError(501, `${command} could not be started: ${e.message}`));
|
|
2970
|
+
});
|
|
2971
|
+
child.on("close", (code) => {
|
|
2972
|
+
clearTimeout(timer2);
|
|
2973
|
+
if (code === 0) resolve5(out);
|
|
2974
|
+
else reject(new AiError(502, `${command} exited ${code}: ${err.trim().slice(0, 400) || "no output"}`));
|
|
2975
|
+
});
|
|
2976
|
+
child.stdin.end(prompt);
|
|
2977
|
+
});
|
|
2978
|
+
}
|
|
2979
|
+
function unwrap(text) {
|
|
2980
|
+
const fenced = text.trim().match(/^```(?:json)?\s*\n([\s\S]*?)\n```$/);
|
|
2981
|
+
return (fenced ? fenced[1] : text).trim();
|
|
2982
|
+
}
|
|
2983
|
+
async function runCli(config, req) {
|
|
2984
|
+
const command = config.model.trim();
|
|
2985
|
+
const wantsTools = (req.tools?.length ?? 0) > 0;
|
|
2986
|
+
if (wantsTools && command !== "claude") {
|
|
2987
|
+
throw new AiError(400, `${command} cannot be given Planche's tools. Use the claude CLI for the AI panel, or a provider with an API key.`);
|
|
2988
|
+
}
|
|
2989
|
+
if (wantsTools && !mcp) {
|
|
2990
|
+
throw new AiError(501, "This agent cannot offer its tools to a CLI. Restart it, or use a provider with an API key.");
|
|
2991
|
+
}
|
|
2992
|
+
if (!isCliCommand(command)) {
|
|
2993
|
+
throw new AiError(400, `Unknown CLI "${command}". Use one of: ${CLI_COMMANDS.join(", ")}.`);
|
|
2994
|
+
}
|
|
2995
|
+
const turns = turnsOf(req);
|
|
2996
|
+
const prompt = [req.system, "", turns.map((t) => "content" in t ? t.content : "").join("\n\n")].join("\n");
|
|
2997
|
+
let text;
|
|
2998
|
+
if (command === "claude") {
|
|
2999
|
+
if (!wantsTools) {
|
|
3000
|
+
text = await run("claude", ["-p"], prompt);
|
|
3001
|
+
} else {
|
|
3002
|
+
const dir = await mkdtemp(join8(tmpdir(), "planche-mcp-"));
|
|
3003
|
+
const file = join8(dir, "mcp.json");
|
|
3004
|
+
try {
|
|
3005
|
+
await writeFile3(
|
|
3006
|
+
file,
|
|
3007
|
+
JSON.stringify({
|
|
3008
|
+
mcpServers: {
|
|
3009
|
+
planche: { type: "http", url: mcp.url, headers: { Authorization: `Bearer ${mcp.token}` } }
|
|
3010
|
+
}
|
|
3011
|
+
}),
|
|
3012
|
+
{ mode: 384 }
|
|
3013
|
+
);
|
|
3014
|
+
text = await run("claude", ["-p", "--strict-mcp-config", "--mcp-config", file], prompt);
|
|
3015
|
+
} finally {
|
|
3016
|
+
await rm(dir, { recursive: true, force: true });
|
|
3017
|
+
}
|
|
3018
|
+
}
|
|
3019
|
+
} else {
|
|
3020
|
+
const dir = await mkdtemp(join8(tmpdir(), "planche-cli-"));
|
|
3021
|
+
const file = join8(dir, "answer.txt");
|
|
3022
|
+
try {
|
|
3023
|
+
await run("codex", ["exec", "--skip-git-repo-check", "-o", file, "-"], prompt);
|
|
3024
|
+
text = await readFile5(file, "utf8");
|
|
3025
|
+
} finally {
|
|
3026
|
+
await rm(dir, { recursive: true, force: true });
|
|
3027
|
+
}
|
|
3028
|
+
}
|
|
3029
|
+
const answer = unwrap(text);
|
|
3030
|
+
if (!answer) throw new AiError(502, `${command} returned nothing.`);
|
|
3031
|
+
return { text: answer, toolCalls: [], stop: "end" };
|
|
3032
|
+
}
|
|
3033
|
+
|
|
3034
|
+
// src/mcp-token.ts
|
|
3035
|
+
import { chmod as chmod2, mkdir as mkdir4, readFile as readFile6, rm as rm2, writeFile as writeFile4 } from "fs/promises";
|
|
3036
|
+
import { randomBytes } from "crypto";
|
|
3037
|
+
import { homedir as homedir8 } from "os";
|
|
3038
|
+
import { join as join9 } from "path";
|
|
3039
|
+
var DIR = join9(homedir8(), ".planche");
|
|
3040
|
+
var FILE = join9(DIR, "mcp-token");
|
|
3041
|
+
var mcpTokenPath = FILE;
|
|
3042
|
+
var cached3 = null;
|
|
3043
|
+
async function readMcpToken() {
|
|
3044
|
+
if (cached3) return cached3;
|
|
3045
|
+
try {
|
|
3046
|
+
const text = (await readFile6(FILE, "utf8")).trim();
|
|
3047
|
+
cached3 = text || null;
|
|
3048
|
+
} catch {
|
|
3049
|
+
cached3 = null;
|
|
3050
|
+
}
|
|
3051
|
+
return cached3;
|
|
3052
|
+
}
|
|
3053
|
+
async function ensureMcpToken() {
|
|
3054
|
+
const existing = await readMcpToken();
|
|
3055
|
+
if (existing) return existing;
|
|
3056
|
+
const token = randomBytes(32).toString("base64url");
|
|
3057
|
+
await mkdir4(DIR, { recursive: true, mode: 448 });
|
|
3058
|
+
await writeFile4(FILE, `${token}
|
|
3059
|
+
`, { mode: 384 });
|
|
3060
|
+
await chmod2(FILE, 384);
|
|
3061
|
+
cached3 = token;
|
|
3062
|
+
return token;
|
|
3063
|
+
}
|
|
3064
|
+
async function forgetMcpToken() {
|
|
3065
|
+
cached3 = null;
|
|
3066
|
+
await rm2(FILE, { force: true });
|
|
3067
|
+
}
|
|
3068
|
+
|
|
2553
3069
|
// src/server.ts
|
|
2554
3070
|
import express from "express";
|
|
2555
3071
|
import { createHash as createHash2, randomUUID as randomUUID2, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
2556
3072
|
|
|
3073
|
+
// src/bridge.ts
|
|
3074
|
+
var CALL_TIMEOUT_MS = 12e4;
|
|
3075
|
+
var client = null;
|
|
3076
|
+
var tools = [];
|
|
3077
|
+
var pending = /* @__PURE__ */ new Map();
|
|
3078
|
+
var nextId = 0;
|
|
3079
|
+
function bridgeTools() {
|
|
3080
|
+
return tools;
|
|
3081
|
+
}
|
|
3082
|
+
function attachBridge(res) {
|
|
3083
|
+
client?.end();
|
|
3084
|
+
client = res;
|
|
3085
|
+
res.on("close", () => {
|
|
3086
|
+
if (client !== res) return;
|
|
3087
|
+
client = null;
|
|
3088
|
+
tools = [];
|
|
3089
|
+
});
|
|
3090
|
+
}
|
|
3091
|
+
function setBridgeTools(next) {
|
|
3092
|
+
tools = next;
|
|
3093
|
+
}
|
|
3094
|
+
function callThroughBridge(name, input) {
|
|
3095
|
+
const target = client;
|
|
3096
|
+
if (!target) {
|
|
3097
|
+
return Promise.resolve({ content: "Planche is not open in a browser, so its tools cannot run.", isError: true });
|
|
3098
|
+
}
|
|
3099
|
+
const id = `call-${++nextId}`;
|
|
3100
|
+
return new Promise((resolve5) => {
|
|
3101
|
+
const timer2 = setTimeout(() => {
|
|
3102
|
+
pending.delete(id);
|
|
3103
|
+
resolve5({ content: `The app did not answer "${name}" within ${CALL_TIMEOUT_MS / 1e3}s.`, isError: true });
|
|
3104
|
+
}, CALL_TIMEOUT_MS);
|
|
3105
|
+
pending.set(id, { resolve: resolve5, timer: timer2 });
|
|
3106
|
+
target.write(`data: ${JSON.stringify({ id, name, input })}
|
|
3107
|
+
|
|
3108
|
+
`);
|
|
3109
|
+
});
|
|
3110
|
+
}
|
|
3111
|
+
function resolveBridgeCall(id, content, isError) {
|
|
3112
|
+
const waiting = pending.get(id);
|
|
3113
|
+
if (!waiting) return;
|
|
3114
|
+
pending.delete(id);
|
|
3115
|
+
clearTimeout(waiting.timer);
|
|
3116
|
+
waiting.resolve({ content, isError });
|
|
3117
|
+
}
|
|
3118
|
+
|
|
3119
|
+
// src/mcp.ts
|
|
3120
|
+
var FALLBACK_PROTOCOL = "2025-06-18";
|
|
3121
|
+
async function handleMcp(req, res) {
|
|
3122
|
+
const msg = req.body ?? {};
|
|
3123
|
+
const reply = (result) => res.json({ jsonrpc: "2.0", id: msg.id, result });
|
|
3124
|
+
switch (msg.method) {
|
|
3125
|
+
case "initialize":
|
|
3126
|
+
reply({
|
|
3127
|
+
protocolVersion: typeof msg.params?.protocolVersion === "string" ? msg.params.protocolVersion : FALLBACK_PROTOCOL,
|
|
3128
|
+
capabilities: { tools: {} },
|
|
3129
|
+
serverInfo: { name: "planche", version: "1" }
|
|
3130
|
+
});
|
|
3131
|
+
return;
|
|
3132
|
+
case "tools/list":
|
|
3133
|
+
reply({
|
|
3134
|
+
tools: bridgeTools().map((tool) => ({
|
|
3135
|
+
name: tool.name,
|
|
3136
|
+
description: tool.description,
|
|
3137
|
+
inputSchema: tool.inputSchema
|
|
3138
|
+
}))
|
|
3139
|
+
});
|
|
3140
|
+
return;
|
|
3141
|
+
case "tools/call": {
|
|
3142
|
+
const name = typeof msg.params?.name === "string" ? msg.params.name : "";
|
|
3143
|
+
const args = msg.params?.arguments ?? {};
|
|
3144
|
+
const { content, isError } = await callThroughBridge(name, args);
|
|
3145
|
+
reply({ content: [{ type: "text", text: content }], isError });
|
|
3146
|
+
return;
|
|
3147
|
+
}
|
|
3148
|
+
default:
|
|
3149
|
+
if (msg.id === void 0) {
|
|
3150
|
+
res.status(202).end();
|
|
3151
|
+
return;
|
|
3152
|
+
}
|
|
3153
|
+
reply({});
|
|
3154
|
+
}
|
|
3155
|
+
}
|
|
3156
|
+
|
|
2557
3157
|
// src/tunneling.ts
|
|
2558
3158
|
import { randomUUID } from "crypto";
|
|
2559
3159
|
|
|
@@ -2844,7 +3444,7 @@ var ConnectionManager = class {
|
|
|
2844
3444
|
|
|
2845
3445
|
// src/server.ts
|
|
2846
3446
|
import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
|
|
2847
|
-
import { join as
|
|
3447
|
+
import { join as join10 } from "path";
|
|
2848
3448
|
function errorStatus(err) {
|
|
2849
3449
|
const status = err?.status;
|
|
2850
3450
|
return typeof status === "number" && status >= 400 && status < 600 ? status : 400;
|
|
@@ -2910,7 +3510,7 @@ function accessControl(allowed) {
|
|
|
2910
3510
|
}
|
|
2911
3511
|
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
2912
3512
|
res.setHeader("Vary", "Origin");
|
|
2913
|
-
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
3513
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
|
|
2914
3514
|
res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type");
|
|
2915
3515
|
res.setHeader("Access-Control-Max-Age", "600");
|
|
2916
3516
|
if (req.headers["access-control-request-private-network"] === "true") {
|
|
@@ -2982,7 +3582,7 @@ function createAgent(options) {
|
|
|
2982
3582
|
options.onUnpaired(pairing, origin);
|
|
2983
3583
|
return true;
|
|
2984
3584
|
}
|
|
2985
|
-
app.get("/health", (req, res) => {
|
|
3585
|
+
app.get("/health", async (req, res) => {
|
|
2986
3586
|
const body = {
|
|
2987
3587
|
success: true,
|
|
2988
3588
|
name: "planche",
|
|
@@ -2991,6 +3591,8 @@ function createAgent(options) {
|
|
|
2991
3591
|
activeConnections: manager.activeConnections,
|
|
2992
3592
|
uptime: process.uptime()
|
|
2993
3593
|
};
|
|
3594
|
+
const ai = await aiConfigView();
|
|
3595
|
+
body.ai = { configured: ai.configured, model: ai.model, tools: true };
|
|
2994
3596
|
if (options.install) body.install = options.install;
|
|
2995
3597
|
if (options.gateway) body.gateway = { name: options.gateway.name, profiles: options.gateway.profiles.length };
|
|
2996
3598
|
if (req.headers.authorization !== void 0) {
|
|
@@ -3015,16 +3617,29 @@ function createAgent(options) {
|
|
|
3015
3617
|
const body = { success: true, token: options.authToken };
|
|
3016
3618
|
res.json(body);
|
|
3017
3619
|
});
|
|
3620
|
+
async function mcpAuthorized(req) {
|
|
3621
|
+
if (req.path !== "/mcp") return false;
|
|
3622
|
+
const given = bearerOf(req.headers.authorization);
|
|
3623
|
+
const expected = await readMcpToken();
|
|
3624
|
+
if (!given || !expected) return false;
|
|
3625
|
+
return secretEquals(given, expected);
|
|
3626
|
+
}
|
|
3018
3627
|
app.use((req, res, next) => {
|
|
3019
3628
|
if (throttle.locked(peerOf(req))) {
|
|
3020
3629
|
fail(res, 429, "Too many failed authentication attempts; try again shortly");
|
|
3021
3630
|
return;
|
|
3022
3631
|
}
|
|
3023
|
-
if (
|
|
3024
|
-
|
|
3632
|
+
if (authorized(req)) {
|
|
3633
|
+
next();
|
|
3025
3634
|
return;
|
|
3026
3635
|
}
|
|
3027
|
-
|
|
3636
|
+
void mcpAuthorized(req).then((ok) => {
|
|
3637
|
+
if (ok) {
|
|
3638
|
+
next();
|
|
3639
|
+
return;
|
|
3640
|
+
}
|
|
3641
|
+
fail(res, 401, "Unauthorized: invalid or missing auth token");
|
|
3642
|
+
});
|
|
3028
3643
|
});
|
|
3029
3644
|
app.use(express.json({ limit: options.bodyLimit }));
|
|
3030
3645
|
app.get("/profiles", (_req, res) => {
|
|
@@ -3098,6 +3713,114 @@ function createAgent(options) {
|
|
|
3098
3713
|
await flushUsage();
|
|
3099
3714
|
res.json(await usageSnapshot());
|
|
3100
3715
|
});
|
|
3716
|
+
app.get("/ai/config", async (_req, res) => {
|
|
3717
|
+
res.json({ success: true, ...await aiConfigView() });
|
|
3718
|
+
});
|
|
3719
|
+
app.put("/ai/config", async (req, res) => {
|
|
3720
|
+
const current = await aiConfigView();
|
|
3721
|
+
if (current.fromEnvironment) {
|
|
3722
|
+
fail(res, 409, "This agent's AI settings come from its environment and cannot be changed from the app.");
|
|
3723
|
+
return;
|
|
3724
|
+
}
|
|
3725
|
+
const body = req.body ?? {};
|
|
3726
|
+
const provider = body.provider === "openai" || body.provider === "gemini" || body.provider === "cli" || body.provider === "anthropic-profile" ? body.provider : "anthropic";
|
|
3727
|
+
const keyless = provider === "cli" || provider === "anthropic-profile";
|
|
3728
|
+
const key = typeof body.key === "string" ? body.key.trim() : "";
|
|
3729
|
+
if (!key && !keyless) {
|
|
3730
|
+
fail(res, 400, "key is required");
|
|
3731
|
+
return;
|
|
3732
|
+
}
|
|
3733
|
+
const model = typeof body.model === "string" && body.model.trim() ? body.model.trim() : defaultModelFor(provider);
|
|
3734
|
+
if (provider === "cli" && !isCliCommand(model)) {
|
|
3735
|
+
fail(res, 400, `model must name an installed CLI: ${CLI_COMMANDS.join(", ")}`);
|
|
3736
|
+
return;
|
|
3737
|
+
}
|
|
3738
|
+
const baseUrl = typeof body.baseUrl === "string" ? body.baseUrl.trim() : "";
|
|
3739
|
+
if (baseUrl && !/^https?:\/\//i.test(baseUrl)) {
|
|
3740
|
+
fail(res, 400, "baseUrl must be an http(s) URL");
|
|
3741
|
+
return;
|
|
3742
|
+
}
|
|
3743
|
+
const written = await saveAiConfig({
|
|
3744
|
+
provider,
|
|
3745
|
+
model,
|
|
3746
|
+
baseUrl: baseUrl || void 0,
|
|
3747
|
+
key
|
|
3748
|
+
}, body.remember === true);
|
|
3749
|
+
console.log(
|
|
3750
|
+
provider === "cli" ? `[agent] AI answers will come from the local "${model}" CLI; no key is held.` : provider === "anthropic-profile" ? "[agent] AI answers will use this machine's own Anthropic credential; no key is held." : written ? `[agent] AI key saved to ${aiConfigPath} (owner-only). Remove it with "Forget key" in Settings, or by deleting that file.` : "[agent] AI key accepted for this session only; it is not written to disk."
|
|
3751
|
+
);
|
|
3752
|
+
res.json({ success: true, ...await aiConfigView() });
|
|
3753
|
+
});
|
|
3754
|
+
app.delete("/ai/config", async (_req, res) => {
|
|
3755
|
+
await forgetAiConfig();
|
|
3756
|
+
res.json({ success: true, ...await aiConfigView() });
|
|
3757
|
+
});
|
|
3758
|
+
app.post("/ai/run", async (req, res) => {
|
|
3759
|
+
const body = req.body ?? {};
|
|
3760
|
+
const hasTurns = Array.isArray(body.messages) && body.messages.length > 0;
|
|
3761
|
+
const hasUser = typeof body.user === "string" && body.user.trim() !== "";
|
|
3762
|
+
if (typeof body.system !== "string" || !hasTurns && !hasUser) {
|
|
3763
|
+
fail(res, 400, "system and one of user/messages are required");
|
|
3764
|
+
return;
|
|
3765
|
+
}
|
|
3766
|
+
try {
|
|
3767
|
+
res.json(await runPrompt({
|
|
3768
|
+
system: body.system,
|
|
3769
|
+
...hasUser ? { user: body.user } : {},
|
|
3770
|
+
...hasTurns ? { messages: body.messages } : {},
|
|
3771
|
+
...Array.isArray(body.tools) && body.tools.length > 0 ? { tools: body.tools } : {},
|
|
3772
|
+
model: typeof body.model === "string" ? body.model : void 0
|
|
3773
|
+
}));
|
|
3774
|
+
} catch (err) {
|
|
3775
|
+
const e = err instanceof AiError ? err : new AiError(502, String(err));
|
|
3776
|
+
fail(res, e.status, e.message);
|
|
3777
|
+
}
|
|
3778
|
+
});
|
|
3779
|
+
app.post("/mcp", async (req, res) => {
|
|
3780
|
+
await handleMcp(req, res);
|
|
3781
|
+
});
|
|
3782
|
+
app.get("/bridge/events", (req, res) => {
|
|
3783
|
+
res.writeHead(200, {
|
|
3784
|
+
"content-type": "text/event-stream",
|
|
3785
|
+
"cache-control": "no-store",
|
|
3786
|
+
connection: "keep-alive"
|
|
3787
|
+
});
|
|
3788
|
+
res.write(": open\n\n");
|
|
3789
|
+
attachBridge(res);
|
|
3790
|
+
});
|
|
3791
|
+
app.post("/bridge/tools", (req, res) => {
|
|
3792
|
+
const tools2 = req.body?.tools;
|
|
3793
|
+
if (!Array.isArray(tools2)) {
|
|
3794
|
+
fail(res, 400, "tools must be an array");
|
|
3795
|
+
return;
|
|
3796
|
+
}
|
|
3797
|
+
setBridgeTools(tools2);
|
|
3798
|
+
res.json({ success: true });
|
|
3799
|
+
});
|
|
3800
|
+
app.post("/bridge/result", (req, res) => {
|
|
3801
|
+
const { id, content, isError } = req.body ?? {};
|
|
3802
|
+
if (typeof id !== "string") {
|
|
3803
|
+
fail(res, 400, "id is required");
|
|
3804
|
+
return;
|
|
3805
|
+
}
|
|
3806
|
+
resolveBridgeCall(id, typeof content === "string" ? content : "", isError === true);
|
|
3807
|
+
res.json({ success: true });
|
|
3808
|
+
});
|
|
3809
|
+
app.post("/mcp/setup", async (_req, res) => {
|
|
3810
|
+
const url = mcpUrl();
|
|
3811
|
+
if (!url) {
|
|
3812
|
+
fail(res, 503, "The agent is not listening yet.");
|
|
3813
|
+
return;
|
|
3814
|
+
}
|
|
3815
|
+
const body = { success: true, url, token: await ensureMcpToken(), path: mcpTokenPath };
|
|
3816
|
+
console.log(`[agent] MCP token handed to the app; stored at ${mcpTokenPath} (owner-only).`);
|
|
3817
|
+
res.json(body);
|
|
3818
|
+
});
|
|
3819
|
+
app.delete("/mcp/setup", async (_req, res) => {
|
|
3820
|
+
await forgetMcpToken();
|
|
3821
|
+
console.log(`[agent] MCP token revoked; ${mcpTokenPath} removed.`);
|
|
3822
|
+
res.json({ success: true });
|
|
3823
|
+
});
|
|
3101
3824
|
app.post("/cancel", async (req, res) => {
|
|
3102
3825
|
try {
|
|
3103
3826
|
const connectionId = req.body?.connectionId;
|
|
@@ -3171,7 +3894,7 @@ function serveApp(app, appDir) {
|
|
|
3171
3894
|
"form-action 'none'",
|
|
3172
3895
|
"frame-ancestors 'none'"
|
|
3173
3896
|
].join("; ");
|
|
3174
|
-
const indexFile =
|
|
3897
|
+
const indexFile = join10(appDir, "index.html");
|
|
3175
3898
|
const indexHtml = existsSync3(indexFile) ? readFileSync3(indexFile, "utf8").replace("<head>", '<head>\n <meta name="planche-agent" content="same-origin" />') : null;
|
|
3176
3899
|
const headers = (res) => {
|
|
3177
3900
|
res.setHeader("Content-Security-Policy", csp);
|
|
@@ -3207,7 +3930,7 @@ var DEV_ORIGINS = [
|
|
|
3207
3930
|
];
|
|
3208
3931
|
function bundledAppDir() {
|
|
3209
3932
|
const dir = fileURLToPath2(new URL("../app/", import.meta.url));
|
|
3210
|
-
return existsSync4(
|
|
3933
|
+
return existsSync4(join11(dir, "index.html")) ? dir : null;
|
|
3211
3934
|
}
|
|
3212
3935
|
function isLoopbackUrl(url) {
|
|
3213
3936
|
try {
|
|
@@ -3230,7 +3953,7 @@ async function appIsReachable(url, timeoutMs = 2500) {
|
|
|
3230
3953
|
}
|
|
3231
3954
|
function resolveAppDir(explicit) {
|
|
3232
3955
|
const dir = explicit ? resolve4(explicit) : fileURLToPath2(new URL("../app/", import.meta.url));
|
|
3233
|
-
if (!existsSync4(
|
|
3956
|
+
if (!existsSync4(join11(dir, "index.html"))) {
|
|
3234
3957
|
console.error(explicit ? `--app-dir has no index.html: ${dir}` : `This build does not include the web app (${dir}). Install the published package, or pass --app-dir <dir>.`);
|
|
3235
3958
|
process.exit(2);
|
|
3236
3959
|
}
|
|
@@ -3242,6 +3965,7 @@ function usage() {
|
|
|
3242
3965
|
Usage: planche [options]
|
|
3243
3966
|
planche upgrade [version] Update a standalone install in place
|
|
3244
3967
|
planche uninstall [--yes] Remove it again (settings in ~/.planche are kept)
|
|
3968
|
+
planche mcp [setup|forget] Print the MCP client configuration, or revoke its token
|
|
3245
3969
|
|
|
3246
3970
|
Options:
|
|
3247
3971
|
-p, --port <n> Port to listen on (default: 8888, env PLANCHE_PORT)
|
|
@@ -3284,10 +4008,36 @@ function openInBrowser(url) {
|
|
|
3284
4008
|
try {
|
|
3285
4009
|
const env = { ...process.env };
|
|
3286
4010
|
delete env.PLANCHE_AUTH_TOKEN;
|
|
3287
|
-
|
|
4011
|
+
spawn2(cmd, args, { stdio: "ignore", detached: true, env }).on("error", () => void 0).unref();
|
|
3288
4012
|
} catch {
|
|
3289
4013
|
}
|
|
3290
4014
|
}
|
|
4015
|
+
async function runMcpCommand(action, port) {
|
|
4016
|
+
if (action === "forget") {
|
|
4017
|
+
await forgetMcpToken();
|
|
4018
|
+
console.log(`Removed ${mcpTokenPath}. Any client configured with it now gets 401.`);
|
|
4019
|
+
return 0;
|
|
4020
|
+
}
|
|
4021
|
+
if (action !== void 0 && action !== "setup") {
|
|
4022
|
+
console.error("Usage: planche mcp [setup|forget]");
|
|
4023
|
+
return 1;
|
|
4024
|
+
}
|
|
4025
|
+
const token = await ensureMcpToken();
|
|
4026
|
+
const url = `http://127.0.0.1:${port}/mcp`;
|
|
4027
|
+
console.log("Add Planche to an agent that speaks MCP over HTTP:\n");
|
|
4028
|
+
console.log(` claude mcp add --transport http planche ${url} \\
|
|
4029
|
+
-H "Authorization: Bearer ${token}"
|
|
4030
|
+
`);
|
|
4031
|
+
console.log("Or as JSON:\n");
|
|
4032
|
+
console.log(` ${JSON.stringify({ mcpServers: { planche: { type: "http", url, headers: { Authorization: `Bearer ${token}` } } } })}
|
|
4033
|
+
`);
|
|
4034
|
+
console.log(`The token is stored at ${mcpTokenPath} (owner-only) and survives restarts, unlike the`);
|
|
4035
|
+
console.log("agent's own token. It reaches this one route and nothing else: calls run inside the");
|
|
4036
|
+
console.log("open browser tab, under its guard rails, and every write stops there for approval.");
|
|
4037
|
+
console.log("Planche must be open with the AI panel showing, or there are no tools to call.");
|
|
4038
|
+
console.log(`Revoke it with "planche mcp forget".`);
|
|
4039
|
+
return 0;
|
|
4040
|
+
}
|
|
3291
4041
|
async function main() {
|
|
3292
4042
|
const [subcommand] = process.argv.slice(2);
|
|
3293
4043
|
if (subcommand === "upgrade") {
|
|
@@ -3296,6 +4046,9 @@ async function main() {
|
|
|
3296
4046
|
if (subcommand === "uninstall") {
|
|
3297
4047
|
process.exit(await runUninstall(process.argv.includes("--yes") || process.argv.includes("-y")));
|
|
3298
4048
|
}
|
|
4049
|
+
if (subcommand === "mcp") {
|
|
4050
|
+
process.exit(await runMcpCommand(process.argv[3], Number(process.env.PLANCHE_PORT ?? 8888)));
|
|
4051
|
+
}
|
|
3299
4052
|
const major = Number(process.versions.node.split(".")[0]);
|
|
3300
4053
|
if (major < 22) {
|
|
3301
4054
|
console.error(`planche needs Node.js 22 or newer (you are running ${process.version}).`);
|
|
@@ -3432,6 +4185,7 @@ async function main() {
|
|
|
3432
4185
|
console.log(`${DEFAULT_APP_URL === hostedAppUrl ? "planche.dev" : hostedAppUrl} is unreachable \u2014 serving the app bundled with this agent (v${VERSION}) instead.`);
|
|
3433
4186
|
}
|
|
3434
4187
|
const server = app.listen(port, listenAddr, () => {
|
|
4188
|
+
configureCliBridge(`http://127.0.0.1:${port}/mcp`, authToken);
|
|
3435
4189
|
console.log(divider);
|
|
3436
4190
|
console.log(`Planche agent ${VERSION} listening on ${agentUrl}`);
|
|
3437
4191
|
console.log(divider);
|