@flow-engineer/flow 0.2.1 → 0.2.2
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/README.md +167 -10
- package/coding-agents/auth-skill.md +137 -0
- package/coding-agents/context-skill.md +94 -0
- package/coding-agents/skill.md +219 -17
- package/coding-agents/test-skill.md +3 -1
- package/coding-agents/tools-skill.md +78 -1
- package/dist/cli/src/auth.d.ts +11 -0
- package/dist/cli/src/auth.js +147 -0
- package/dist/cli/src/commands.d.ts +119 -0
- package/dist/cli/src/commands.js +212 -0
- package/dist/cli/src/index.d.ts +2 -0
- package/dist/cli/src/index.js +5 -0
- package/dist/cli/src/run.d.ts +28 -0
- package/dist/cli/src/run.js +523 -0
- package/dist/cli/src/terminal.d.ts +3 -0
- package/dist/cli/src/terminal.js +135 -0
- package/dist/flow-messaging/src/index.js +7 -1
- package/dist/mcp/src/client.d.ts +54 -0
- package/dist/mcp/src/client.js +175 -0
- package/dist/mcp/src/describe.d.ts +41 -0
- package/dist/mcp/src/describe.js +110 -0
- package/dist/mcp/src/index.d.ts +4 -0
- package/dist/mcp/src/index.js +7 -0
- package/dist/mcp/src/server.d.ts +70 -0
- package/dist/mcp/src/server.js +252 -0
- package/dist/mcp/src/terminal.d.ts +24 -0
- package/dist/mcp/src/terminal.js +54 -0
- package/dist/simulators/src/index.d.ts +1 -1
- package/dist/simulators/src/page.html +267 -49
- package/dist/simulators/src/server.d.ts +27 -0
- package/dist/simulators/src/server.js +264 -40
- package/dist/src/agent.d.ts +69 -3
- package/dist/src/agent.js +162 -8
- package/dist/src/channels.d.ts +2 -0
- package/dist/src/conversation.d.ts +16 -1
- package/dist/src/conversation.js +140 -12
- package/dist/src/events.d.ts +1 -1
- package/dist/src/index.d.ts +2 -2
- package/dist/src/index.js +1 -1
- package/dist/src/model.d.ts +27 -0
- package/dist/src/model.js +85 -13
- package/dist/src/skills.d.ts +4 -0
- package/dist/src/skills.js +23 -0
- package/dist/src/store.d.ts +2 -0
- package/dist/src/store.js +18 -0
- package/dist/src/tools.d.ts +69 -0
- package/dist/src/tools.js +17 -1
- package/dist/src/traces.js +32 -4
- package/dist/src/types.d.ts +2 -0
- package/dist/testing/src/account.d.ts +1 -1
- package/dist/testing/src/account.js +368 -57
- package/dist/testing/src/chat.js +24 -16
- package/dist/testing/src/check.d.ts +4 -0
- package/dist/testing/src/check.js +90 -0
- package/dist/testing/src/cli.js +77 -19
- package/dist/testing/src/flow-login.d.ts +33 -0
- package/dist/testing/src/flow-login.js +30 -5
- package/dist/testing/src/new.d.ts +14 -5
- package/dist/testing/src/new.js +54 -20
- package/dist/testing/src/plugin.d.ts +4 -0
- package/dist/testing/src/plugin.js +116 -0
- package/docs/agents.md +87 -12
- package/examples/cafe/src/cli.ts +24 -0
- package/examples/cafe/src/mcp.ts +22 -0
- package/examples/cafe/src/tools.ts +13 -0
- package/package.json +28 -1
|
@@ -18,22 +18,31 @@ import { hostname } from "node:os";
|
|
|
18
18
|
import { basename, join } from "node:path";
|
|
19
19
|
import { parseArgs } from "node:util";
|
|
20
20
|
import { setTimeout as delay } from "node:timers/promises";
|
|
21
|
-
import { currentToken, deleteCredentials, deletePending, LoginRefused, loginUrl, pollToken, readPending, REQUEST_TIMEOUT_MS, revoke, saveCredentials, savePending, SETUP_ENV, setup, startDevice, TimedOut, tryLink, warningsOf, whoami, } from "./flow-login.js";
|
|
21
|
+
import { currentToken, deleteCredentials, deletePending, LoginRefused, loginUrl, pollToken, readPending, REQUEST_TIMEOUT_MS, revoke, saveCredentials, savePending, modelCredit, SETUP_ENV, setup, startDevice, TimedOut, tryLink, warningsOf, whoami, } from "./flow-login.js";
|
|
22
22
|
import { packageRoot } from "./new.js";
|
|
23
|
+
import { flowModelURL, hasModelKey, MODEL_KEY_NAMES, NO_MODEL_KEY } from "../../src/model.js";
|
|
23
24
|
import { RUN } from "../../src/names.js";
|
|
24
|
-
export const ACCOUNT_COMMANDS = new Set(["login", "logout", "whoami", "setup", "link"]);
|
|
25
|
+
export const ACCOUNT_COMMANDS = new Set(["login", "logout", "whoami", "setup", "link", "status"]);
|
|
25
26
|
export const ACCOUNT_LINES = ` flow login [--wait | --no-wait] [--timeout <s>] [--force] [--no-browser] [--json]
|
|
26
27
|
Sign in to flow.engineer: opens the browser, prints the link and code,
|
|
27
28
|
and waits up to --timeout (default 120) for approval; --wait goes on
|
|
28
29
|
waiting for the same code, --no-wait returns at once
|
|
29
30
|
flow logout [--json] End this machine's sign-in and delete its token
|
|
30
31
|
flow whoami [--json] Show the account you are signed in as
|
|
31
|
-
flow setup [--replace] [--ci] [--json]
|
|
32
|
-
Write this agent's keys (
|
|
33
|
-
|
|
32
|
+
flow setup [--replace] [--rotate <keys> [--now]] [--ci] [--json]
|
|
33
|
+
Write this agent's keys (its Flow key FLOW_KEY, which also pays for the
|
|
34
|
+
model on the account's trial credit when there is no provider key, test
|
|
35
|
+
cloud, traces, Flow Messaging) and each open channel's sender into
|
|
36
|
+
./.env (in an agent's folder: package.json); --rotate key,cloud,traces,
|
|
37
|
+
messaging or all makes those keys again (the old FLOW_KEY works 24 h
|
|
38
|
+
more; --now ends it at once)
|
|
34
39
|
flow link [--channel telegram|whatsapp|imessage] [--timeout <s>] [--no-browser] [--json]
|
|
35
40
|
Print the link that opens a chat with this agent from a phone; signs in
|
|
36
|
-
and runs setup first when this folder is not set up yet
|
|
41
|
+
and runs setup first when this folder is not set up yet
|
|
42
|
+
flow status [--json] What this folder has and lacks: FLOW_KEY and the model key (never their
|
|
43
|
+
values) with the trial credit left, the
|
|
44
|
+
sign-in, each channel's sender in .env, and, signed in, which channels
|
|
45
|
+
are open now and their free-trial counts`;
|
|
37
46
|
const USAGE = `Usage:\n${ACCOUNT_LINES}
|
|
38
47
|
|
|
39
48
|
The token is kept in ~/.config/flow-framework/credentials.json (mode 0600); FLOW_FRAMEWORK_TOKEN, when
|
|
@@ -86,7 +95,7 @@ export async function accountCli(command, args) {
|
|
|
86
95
|
force: { type: "boolean", default: false },
|
|
87
96
|
}
|
|
88
97
|
: {}),
|
|
89
|
-
...(command === "setup" ? { replace: { type: "boolean", default: false }, ci: { type: "boolean", default: false } } : {}),
|
|
98
|
+
...(command === "setup" ? { replace: { type: "boolean", default: false }, rotate: { type: "string" }, now: { type: "boolean", default: false }, ci: { type: "boolean", default: false } } : {}),
|
|
90
99
|
...(command === "link" ? { channel: { type: "string", default: "telegram" } } : {}),
|
|
91
100
|
},
|
|
92
101
|
}));
|
|
@@ -109,6 +118,15 @@ export async function accountCli(command, args) {
|
|
|
109
118
|
console.error(`flow link: --channel takes ${CHANNELS.join(", ")}.\n\n${USAGE}`);
|
|
110
119
|
return EXIT.usage;
|
|
111
120
|
}
|
|
121
|
+
const rotate = values.rotate === undefined ? [] : String(values.rotate).split(",").map((k) => k.trim());
|
|
122
|
+
if (rotate.some((k) => !(k in ROTATE)) || (rotate.includes("all") && rotate.length > 1)) {
|
|
123
|
+
console.error(`flow setup: --rotate takes ${Object.keys(ROTATE).filter((k) => k !== "all").join(", ")} (comma-separated), or all.\n\n${USAGE}`);
|
|
124
|
+
return EXIT.usage;
|
|
125
|
+
}
|
|
126
|
+
if (values.now === true && !rotate.includes("key") && !rotate.includes("all")) {
|
|
127
|
+
console.error(`flow setup: --now goes with --rotate key (or all): it ends the old FLOW_KEY at once.\n\n${USAGE}`);
|
|
128
|
+
return EXIT.usage;
|
|
129
|
+
}
|
|
112
130
|
const json = values.json === true;
|
|
113
131
|
// Straight to the file descriptors, never buffered: a coding agent's cut-off command keeps every line.
|
|
114
132
|
const out = (report) => writeAll(1, `${JSON.stringify(report)}\n`);
|
|
@@ -121,8 +139,10 @@ export async function accountCli(command, args) {
|
|
|
121
139
|
await logout(json, out, say);
|
|
122
140
|
else if (command === "whoami")
|
|
123
141
|
await showAccount(json, out, say);
|
|
142
|
+
else if (command === "status")
|
|
143
|
+
await status(json, out, say);
|
|
124
144
|
else if (command === "setup")
|
|
125
|
-
await setupEnv(values.replace === true, values.ci === true, json, out, say);
|
|
145
|
+
await setupEnv({ replace: values.replace === true, rotate, now: values.now === true, ci: values.ci === true }, json, out, say);
|
|
126
146
|
else
|
|
127
147
|
await link({ channel, noBrowser: values["no-browser"] === true, timeoutSeconds }, json, out, say);
|
|
128
148
|
return EXIT.ok;
|
|
@@ -362,9 +382,11 @@ async function showAccount(json, out, say) {
|
|
|
362
382
|
out(account);
|
|
363
383
|
say(`Signed in to flow.engineer as ${account.email} (account ${account.account}).`);
|
|
364
384
|
}
|
|
365
|
-
const SECRET = new Set([SETUP_ENV.cloud_key, SETUP_ENV.traces_key, SETUP_ENV.messaging_key]);
|
|
385
|
+
const SECRET = new Set([SETUP_ENV.cloud_key, SETUP_ENV.traces_key, SETUP_ENV.messaging_key, ...MODEL_KEY_NAMES]);
|
|
366
386
|
const masked = (name, value) => (SECRET.has(name) ? `${/^[a-z]+_(?:test_|live_)?/.exec(value)?.[0] ?? ""}…${value.slice(-4)}` : value);
|
|
367
387
|
const ENV_LINE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/;
|
|
388
|
+
/** `--rotate`'s names and the answer field each makes again; `all` is every key. */
|
|
389
|
+
const ROTATE = { key: "flow_key", cloud: "cloud_key", traces: "traces_key", messaging: "messaging_key", all: "" };
|
|
368
390
|
/** The names set in a .env's lines, with their values unquoted. */
|
|
369
391
|
function envValues(lines) {
|
|
370
392
|
const values = new Map();
|
|
@@ -389,8 +411,76 @@ function agentFolder(dir) {
|
|
|
389
411
|
return;
|
|
390
412
|
throw new Stop("not_an_agent_folder", `${dir} has no package.json, so it is not an agent's folder; its keys would land in the wrong .env.`, `cd into your agent's folder, or make an agent here first: ${RUN} init`, EXIT.usage);
|
|
391
413
|
}
|
|
392
|
-
|
|
393
|
-
|
|
414
|
+
/** The agent's one Flow key; for the model it is last in the model keys' order, so a provider key the owner sets wins. */
|
|
415
|
+
const FLOW_KEY = MODEL_KEY_NAMES.at(-1);
|
|
416
|
+
const USED_UP = "This account's Flow trial credit is used up.";
|
|
417
|
+
const NO_CREDIT = `Flow has no trial model credit for this account, so ${FLOW_KEY} cannot pay for the model.`;
|
|
418
|
+
const OWN_KEY = "add your own model key to .env";
|
|
419
|
+
/** setup's model_credit or GET /credit's answer as FlowCredit: its numbers only. */
|
|
420
|
+
function creditOf(from) {
|
|
421
|
+
if (!from)
|
|
422
|
+
return null;
|
|
423
|
+
const credit = {};
|
|
424
|
+
for (const field of ["limit_usd", "left_usd", "spent_usd"]) {
|
|
425
|
+
const n = from[field];
|
|
426
|
+
if (typeof n === "number" && Number.isFinite(n))
|
|
427
|
+
credit[field] = n;
|
|
428
|
+
}
|
|
429
|
+
return credit;
|
|
430
|
+
}
|
|
431
|
+
const dollars = (n) => `$${n.toFixed(2)}`;
|
|
432
|
+
/**
|
|
433
|
+
* The model key's state: `line` to print, and with no key the step that gets one, which goes first
|
|
434
|
+
* in the run's one `next`. `credit`: Flow's credit from setup's answer or GET /credit. `afterSetup`:
|
|
435
|
+
* setup ran just now, so its answer says why there is none.
|
|
436
|
+
*/
|
|
437
|
+
function modelKeyReport(value, credit, afterSetup) {
|
|
438
|
+
const env = MODEL_KEY_NAMES.find((name) => value(name)) ?? null;
|
|
439
|
+
const flowCredit = creditOf(credit);
|
|
440
|
+
const usedUp = flowCredit?.left_usd !== undefined && flowCredit.left_usd <= 0;
|
|
441
|
+
if (env && env !== FLOW_KEY)
|
|
442
|
+
return { report: { set: true, env, flow_credit: null }, line: `Model: ${env} is set` };
|
|
443
|
+
if (env) {
|
|
444
|
+
if (usedUp)
|
|
445
|
+
return { report: { set: true, env, flow_credit: flowCredit, message: USED_UP }, line: USED_UP, next: OWN_KEY };
|
|
446
|
+
// Setup just ran and gave no credit (warnings say why): FLOW_KEY is there, but cannot pay for the model.
|
|
447
|
+
if (afterSetup && !flowCredit)
|
|
448
|
+
return { report: { set: true, env, flow_credit: null, message: NO_CREDIT }, line: NO_CREDIT, next: OWN_KEY };
|
|
449
|
+
const left = flowCredit?.left_usd !== undefined ? ` (${dollars(flowCredit.left_usd)} left)` : "";
|
|
450
|
+
return { report: { set: true, env, flow_credit: flowCredit }, line: `Model: Flow trial credit${left}` };
|
|
451
|
+
}
|
|
452
|
+
// No key, and the account's credit is used up: setup made none.
|
|
453
|
+
if (usedUp)
|
|
454
|
+
return { report: { set: false, env: null, flow_credit: flowCredit, message: USED_UP }, line: USED_UP, next: OWN_KEY };
|
|
455
|
+
const report = { set: false, env: null, flow_credit: null, message: NO_MODEL_KEY.message };
|
|
456
|
+
// Setup ran and flow.engineer gave no FLOW_KEY (an older server, or none for this account now).
|
|
457
|
+
if (afterSetup)
|
|
458
|
+
return { report, line: NO_MODEL_KEY.message, next: `${OWN_KEY} (flow.engineer gave no trial credit)` };
|
|
459
|
+
return { report, line: NO_MODEL_KEY.message, next: NO_MODEL_KEY.nextLine.replace(/^Next: /, "").replace(/\.$/, "") };
|
|
460
|
+
}
|
|
461
|
+
/** What people call this folder's agent (package.json displayName, from `flow init`), else its package name in words. */
|
|
462
|
+
function agentDisplayName(dir = process.cwd()) {
|
|
463
|
+
const file = join(dir, "package.json");
|
|
464
|
+
const pkg = existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : {};
|
|
465
|
+
const named = typeof pkg.displayName === "string" ? pkg.displayName.trim() : "";
|
|
466
|
+
return named || (pkg.name ?? basename(dir)).replace(/^@[^/]+\//, "").replace(/-/g, " ");
|
|
467
|
+
}
|
|
468
|
+
/** The /try page with the agent's name (`n`), so it fills in "Hi <name> (ref <code>)". */
|
|
469
|
+
function namedPage(url, name) {
|
|
470
|
+
const parsed = URL.parse(url);
|
|
471
|
+
if (!parsed)
|
|
472
|
+
return url;
|
|
473
|
+
parsed.searchParams.set("n", name);
|
|
474
|
+
return parsed.toString();
|
|
475
|
+
}
|
|
476
|
+
/** How to use each channel's direct link. */
|
|
477
|
+
const OPEN_HINT = {
|
|
478
|
+
telegram: "Open it and press Start.",
|
|
479
|
+
whatsapp: "Open it and send the message it fills in.",
|
|
480
|
+
imessage: "Open it on an iPhone or a Mac and send the message it fills in.",
|
|
481
|
+
};
|
|
482
|
+
async function setupEnv(options, json, out, say, forLink) {
|
|
483
|
+
if (options.ci) {
|
|
394
484
|
const steps = [
|
|
395
485
|
`CI never signs in. It runs the tests with the agent's test cloud key, ${SETUP_ENV.cloud_key}, as a secret.`,
|
|
396
486
|
`Run ${NEXT.setup} on your machine first, then copy the key from .env into your CI without printing it, for GitHub:`,
|
|
@@ -401,7 +491,7 @@ async function setupEnv(replace, ci, json, out, say, forLink) {
|
|
|
401
491
|
out({ ci: { secret: SETUP_ENV.cloud_key, steps } });
|
|
402
492
|
for (const line of steps)
|
|
403
493
|
say(line);
|
|
404
|
-
return;
|
|
494
|
+
return { answer: {} };
|
|
405
495
|
}
|
|
406
496
|
const dir = process.cwd();
|
|
407
497
|
agentFolder(dir);
|
|
@@ -415,24 +505,39 @@ async function setupEnv(replace, ci, json, out, say, forLink) {
|
|
|
415
505
|
const named = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).name ?? basename(dir);
|
|
416
506
|
// The server takes 1 to 64 letters, digits, spaces, -, _ and .: a package's scope and other characters go.
|
|
417
507
|
const name = named.replace(/^@[^/]*\//, "").replace(/[^A-Za-z0-9 ._-]/g, "-").slice(0, 64) || "my-agent";
|
|
418
|
-
|
|
419
|
-
const
|
|
508
|
+
const all = options.rotate.includes("all");
|
|
509
|
+
const rotated = new Set(all ? Object.values(ROTATE).filter(Boolean) : options.rotate.map((k) => ROTATE[k]));
|
|
510
|
+
const rotatedEnv = new Set([...rotated].map((field) => (field === "flow_key" ? FLOW_KEY : SETUP_ENV[field])));
|
|
511
|
+
// An empty value (`cp .env.example .env`) is missing: the server mints it. A model key counts in
|
|
512
|
+
// the shell too (npm start reads both): FLOW_KEY is made only when it is not named as had, and
|
|
513
|
+
// the trial model credit is given only when no provider key is.
|
|
514
|
+
const have = [
|
|
515
|
+
...Object.values(SETUP_ENV).filter((n) => n !== SETUP_ENV.try_url && before.get(n)),
|
|
516
|
+
...MODEL_KEY_NAMES.filter((n) => before.get(n) || process.env[n]?.trim()),
|
|
517
|
+
].filter((n) => !rotatedEnv.has(n));
|
|
518
|
+
// Dropping a key's name from `have` makes it again; `rotate` names them too, so the server replaces
|
|
519
|
+
// (and, for FLOW_KEY, ends) the old one rather than adding a second.
|
|
520
|
+
const rotate = all ? true : rotated.size ? [...rotated] : undefined;
|
|
521
|
+
const rotateNow = options.now === true && rotated.has("flow_key");
|
|
420
522
|
let answer;
|
|
421
523
|
try {
|
|
422
524
|
// Every channel: each comes back with its shared sender only while it is open.
|
|
423
|
-
answer = await setup(url, token, { name, hostname: hostname(), channels: [...CHANNELS], have });
|
|
525
|
+
answer = await setup(url, token, { name, hostname: hostname(), channels: [...CHANNELS], have, ...(rotate ? { rotate } : {}), ...(rotateNow ? { rotate_now: true } : {}) });
|
|
424
526
|
}
|
|
425
527
|
catch (error) {
|
|
426
528
|
// The keys minted anyway are not shown again, so whatever the error carries goes into .env first.
|
|
427
529
|
if (error instanceof LoginRefused) {
|
|
428
|
-
const written = writeEnv(file, lines, before, error.fields, replace);
|
|
530
|
+
const written = writeEnv(file, lines, before, envEntries(error.fields), (n) => options.replace || rotatedEnv.has(n));
|
|
429
531
|
if (written.some((w) => w.action === "added" || w.action === "replaced"))
|
|
430
532
|
say(`Kept in .env before the error: ${written.map((w) => `${w.name} ${w.action}`).join(", ")}.`);
|
|
431
533
|
}
|
|
432
534
|
throw error;
|
|
433
535
|
}
|
|
434
|
-
const
|
|
435
|
-
const
|
|
536
|
+
const fields = answer;
|
|
537
|
+
const warnings = warningsOf(fields);
|
|
538
|
+
const had = new Map(before);
|
|
539
|
+
// --replace takes every new value but a model key: only --rotate key replaces FLOW_KEY.
|
|
540
|
+
const written = writeEnv(file, lines, before, envEntries(fields), (n) => rotatedEnv.has(n) || (options.replace && !MODEL_KEY_NAMES.includes(n)));
|
|
436
541
|
if (typeof answer.join_code !== "string" || !answer.join_code) {
|
|
437
542
|
const kept = written.filter((w) => w.action === "added" || w.action === "replaced").map((w) => w.name);
|
|
438
543
|
const stop = new Stop("no_join_code", `flow.engineer gave no join code${kept.length ? ` (the keys it made are in .env: ${kept.join(", ")})` : ""}, so there is no phone link yet.`, answer.next?.[0] ?? NEXT.setup, EXIT.refused);
|
|
@@ -440,36 +545,87 @@ async function setupEnv(replace, ci, json, out, say, forLink) {
|
|
|
440
545
|
throw stop;
|
|
441
546
|
}
|
|
442
547
|
printWarnings(warnings);
|
|
443
|
-
const open = CHANNELS.filter((c) => typeof
|
|
444
|
-
const
|
|
445
|
-
const
|
|
548
|
+
const open = CHANNELS.filter((c) => typeof fields[`${c}_sender`] === "string");
|
|
549
|
+
const displayName = agentDisplayName(dir);
|
|
550
|
+
const links = Object.fromEntries(open.map((c) => [c, namedPage(answer.try_urls?.[c] ?? (c === "telegram" && answer.try_url ? answer.try_url : tryLink(url, answer.join_code, c)), displayName)]));
|
|
551
|
+
// Said by link instead, which asks for its own; a page that does not answer leaves only the page.
|
|
552
|
+
const direct = {};
|
|
553
|
+
if (!forLink) {
|
|
554
|
+
for (const c of open) {
|
|
555
|
+
const found = await directLink(links[c]).catch((error) => (error instanceof Stop ? undefined : Promise.reject(error)));
|
|
556
|
+
if (found)
|
|
557
|
+
direct[c] = found;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
// A closed channel's trial counts are not shown: only the open ones'.
|
|
561
|
+
const trial = answer.trial ? Object.fromEntries(Object.entries(answer.trial).filter(([c]) => open.includes(c))) : null;
|
|
562
|
+
const model = modelKeyReport((n) => before.get(n) || process.env[n]?.trim() || undefined, objectField(fields, "model_credit"), true);
|
|
563
|
+
const flowKey = objectField(fields, "flow_key");
|
|
564
|
+
// A rotated FLOW_KEY: when the one it replaced stops working.
|
|
565
|
+
const previous = rotated.has("flow_key") && flowKey?.minted
|
|
566
|
+
? flowKey.previous_expires_at ? `The old ${FLOW_KEY} works until ${flowKey.previous_expires_at}.` : rotateNow ? `The old ${FLOW_KEY} stopped working now.` : undefined
|
|
567
|
+
: undefined;
|
|
568
|
+
// A first run's order: a model key if there is none, the playground (npm start), then the phone.
|
|
569
|
+
// chat and test --synthetic are the coding agent's own checks, not the owner's way to test.
|
|
570
|
+
const next = [...(model.next ? [model.next] : []), "npm start", ...(answer.next?.length ? answer.next : [NEXT.link])];
|
|
446
571
|
if (json) {
|
|
447
572
|
out({
|
|
448
573
|
agent: answer.agent_id, join_code: answer.join_code, env_written: { ".env": written }, minted: answer.minted ?? [],
|
|
449
574
|
dashboard_url: answer.dashboard_url ?? null, try_url: answer.try_url ?? null, telegram_link: answer.telegram_link ?? null,
|
|
450
|
-
whatsapp_link: answer.whatsapp_link ?? null, imessage_link: answer.imessage_link ?? null, try_urls: links,
|
|
575
|
+
whatsapp_link: answer.whatsapp_link ?? null, imessage_link: answer.imessage_link ?? null, try_urls: links,
|
|
576
|
+
direct_links: Object.fromEntries(Object.entries(direct).map(([c, d]) => [c, d.link])), join_message: Object.values(direct)[0]?.message ?? null,
|
|
577
|
+
trial, model_key: model.report,
|
|
578
|
+
// FLOW_KEY's state, never its value: made in this call, with its id, mode and scopes.
|
|
579
|
+
flow_key: { set: Boolean(before.get(FLOW_KEY) || process.env[FLOW_KEY]?.trim()), minted: flowKey?.minted === true,
|
|
580
|
+
...(flowKey?.minted ? { kid: flowKey.kid ?? null, mode: flowKey.mode ?? null, scopes: flowKey.scopes ?? [], previous_expires_at: flowKey.previous_expires_at ?? null } : {}) },
|
|
581
|
+
// link's own line (or its error) has the run's next steps: one list per run.
|
|
582
|
+
...(forLink ? {} : { next }), warnings,
|
|
451
583
|
});
|
|
452
584
|
}
|
|
453
585
|
say(`Agent ${answer.agent_id}. In .env:`);
|
|
454
586
|
for (const w of written)
|
|
455
|
-
say(` ${w.name}=${w.value} ${w.action}${w.action === "kept" ? " (yours differs; --replace takes the new one)" : ""}`);
|
|
587
|
+
say(` ${w.name}=${w.value} ${w.action}${w.action === "kept" ? (w.name === FLOW_KEY ? " (--rotate key makes a new one)" : " (yours differs; --replace takes the new one)") : ""}`);
|
|
588
|
+
if (previous)
|
|
589
|
+
say(previous);
|
|
456
590
|
if (answer.dashboard_url)
|
|
457
591
|
say(`Its dashboard: ${answer.dashboard_url}`);
|
|
458
|
-
|
|
592
|
+
// Said by link instead, once, with its link.
|
|
593
|
+
if (!forLink)
|
|
594
|
+
say(model.line);
|
|
595
|
+
const people = trial?.imessage?.contacts;
|
|
459
596
|
if (people && typeof people.used === "number" && typeof people.limit === "number")
|
|
460
597
|
say(`iMessage: ${people.used} of ${people.limit} people`);
|
|
461
598
|
if (!forLink) {
|
|
462
599
|
for (const c of open)
|
|
463
|
-
say(`On ${CHANNEL_NAME[c]}: ${links[c]}${trialNote(
|
|
600
|
+
say(`On ${CHANNEL_NAME[c]}: ${direct[c] ? `${direct[c].link} (on a computer: ${links[c]})` : links[c]}${trialNote(trial?.[c])}`);
|
|
601
|
+
const added = addedToRunning((n) => had.get(n) || process.env[n]?.trim(), (n) => before.get(n) || process.env[n]?.trim());
|
|
602
|
+
if (added)
|
|
603
|
+
say(added);
|
|
464
604
|
say(`Next: ${next.join(", then ")}`);
|
|
465
605
|
}
|
|
606
|
+
return { answer };
|
|
466
607
|
}
|
|
467
608
|
const trialNote = (t) => (t && typeof t.limit === "number" ? ` (free trial: ${t.used} of ${t.limit} messages used)` : "");
|
|
468
|
-
/**
|
|
469
|
-
function
|
|
609
|
+
/** An answer's field when it is an object (flow_key, model_credit), else undefined (an older server sends none). */
|
|
610
|
+
function objectField(fields, name) {
|
|
611
|
+
const value = fields[name];
|
|
612
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* The .env lines an answer gives: SETUP_ENV's fields, then the agent's Flow key as FLOW_KEY (no
|
|
616
|
+
* other name: a provider key the owner typed is never written over, and wins for the model).
|
|
617
|
+
*/
|
|
618
|
+
function envEntries(fields) {
|
|
619
|
+
const entries = Object.entries(SETUP_ENV).map(([field, envName]) => [envName, fields[field]]);
|
|
620
|
+
const key = objectField(fields, "flow_key");
|
|
621
|
+
if ((key?.env ?? FLOW_KEY) === FLOW_KEY && typeof key?.value === "string")
|
|
622
|
+
entries.push([FLOW_KEY, key.value]);
|
|
623
|
+
return entries;
|
|
624
|
+
}
|
|
625
|
+
/** Merges the entries into .env's lines and writes the file (mode 0600) when anything changed; `replace(name)` says when a value there gives way. */
|
|
626
|
+
function writeEnv(file, lines, before, entries, replace) {
|
|
470
627
|
const written = [];
|
|
471
|
-
for (const [
|
|
472
|
-
const value = fields[field];
|
|
628
|
+
for (const [envName, value] of entries) {
|
|
473
629
|
if (typeof value !== "string" || !value)
|
|
474
630
|
continue;
|
|
475
631
|
const old = before.get(envName);
|
|
@@ -485,7 +641,7 @@ function writeEnv(file, lines, before, fields, replace) {
|
|
|
485
641
|
}
|
|
486
642
|
else if (old === value)
|
|
487
643
|
action = "unchanged";
|
|
488
|
-
else if (!replace)
|
|
644
|
+
else if (!replace(envName))
|
|
489
645
|
action = "kept";
|
|
490
646
|
else {
|
|
491
647
|
lines[at] = `${envName}=${value}`;
|
|
@@ -500,25 +656,123 @@ function writeEnv(file, lines, before, fields, replace) {
|
|
|
500
656
|
chmodSync(file, 0o600); // a .env that was there keeps its old mode on write
|
|
501
657
|
return written;
|
|
502
658
|
}
|
|
659
|
+
/**
|
|
660
|
+
* What a running agent (npm start reads .env once, when it starts) lacks after .env changed from
|
|
661
|
+
* `had` to `now`: the channels it is not on yet and a model key, as one sentence; undefined when
|
|
662
|
+
* nothing it uses is new.
|
|
663
|
+
*/
|
|
664
|
+
function addedToRunning(had, now) {
|
|
665
|
+
// The starter is on Telegram whenever it has the Flow Messaging key, and on WhatsApp and iMessage
|
|
666
|
+
// when .env names their senders; without the key it was on no channel at all.
|
|
667
|
+
const on = (env, c) => Boolean(env(SETUP_ENV.messaging_key) && (c === "telegram" || env(SENDER_ENV[c])));
|
|
668
|
+
const channels = CHANNELS.filter((c) => on(now, c) && !on(had, c)).map((c) => CHANNEL_NAME[c]);
|
|
669
|
+
const model = !MODEL_KEY_NAMES.some((n) => had(n)) && MODEL_KEY_NAMES.some((n) => now(n)) ? ["the model key"] : [];
|
|
670
|
+
const what = [...channels, ...model];
|
|
671
|
+
if (!what.length)
|
|
672
|
+
return undefined;
|
|
673
|
+
return `If npm start is running, restart it to add ${what.length === 1 ? what[0] : `${what.slice(0, -1).join(", ")} and ${what.at(-1)}`}.`;
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* `flow status`: what this folder has and lacks, read from .env and the environment, and, when
|
|
677
|
+
* signed in and set up, flow.engineer's answer for which channels are open now and their trial
|
|
678
|
+
* counts. It changes nothing: setup is asked with every key named as had, so nothing is made, and
|
|
679
|
+
* its answer is not written. Not signed in, or flow.engineer unreachable: that part is left out.
|
|
680
|
+
*/
|
|
681
|
+
async function status(json, out, say) {
|
|
682
|
+
const value = dotenvValue();
|
|
683
|
+
const model = await flowModelStatus(value);
|
|
684
|
+
const joinCode = value(SETUP_ENV.join_code) ?? null;
|
|
685
|
+
let account = null;
|
|
686
|
+
let server = null;
|
|
687
|
+
const current = currentToken();
|
|
688
|
+
const url = loginUrl();
|
|
689
|
+
if (current && (!current.url || current.url === url)) {
|
|
690
|
+
try {
|
|
691
|
+
account = await whoami(url, current.token);
|
|
692
|
+
if (joinCode && existsSync(join(process.cwd(), "package.json"))) {
|
|
693
|
+
const named = JSON.parse(readFileSync(join(process.cwd(), "package.json"), "utf8")).name ?? basename(process.cwd());
|
|
694
|
+
const name = named.replace(/^@[^/]*\//, "").replace(/[^A-Za-z0-9 ._-]/g, "-").slice(0, 64) || "my-agent";
|
|
695
|
+
const have = [...Object.values(SETUP_ENV).filter((n) => n !== SETUP_ENV.try_url), ...MODEL_KEY_NAMES];
|
|
696
|
+
server = await setup(url, current.token, { name, hostname: hostname(), channels: [...CHANNELS], have });
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
catch {
|
|
700
|
+
// Signed out on the server, or not reachable: status says what it can from here.
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
const fields = (server ?? {});
|
|
704
|
+
const channels = Object.fromEntries(CHANNELS.map((c) => [c, {
|
|
705
|
+
sender: Boolean(c === "telegram" ? value(SETUP_ENV.messaging_key) : value(SENDER_ENV[c])),
|
|
706
|
+
open: server ? typeof fields[`${c}_sender`] === "string" : null,
|
|
707
|
+
trial: server && typeof fields[`${c}_sender`] === "string" ? server.trial?.[c] ?? null : null,
|
|
708
|
+
}]));
|
|
709
|
+
// The playground (npm start) before the phone, as setup's next.
|
|
710
|
+
const next = [
|
|
711
|
+
...(model.next ? [model.next] : []),
|
|
712
|
+
...(model.report.set && !model.next ? ["npm start"] : []),
|
|
713
|
+
...(!joinCode || !value(SETUP_ENV.messaging_key) ? [NEXT.link] : CHANNELS.filter((c) => channels[c].open && !channels[c].sender).map((c) => `${NEXT.link} --channel ${c}`)),
|
|
714
|
+
];
|
|
715
|
+
if (json)
|
|
716
|
+
out({ model_key: model.report, flow_key: { set: Boolean(value(FLOW_KEY)) }, signed_in: account ? { email: account.email, account: account.account } : false, join_code: joinCode, messaging_key: Boolean(value(SETUP_ENV.messaging_key)), channels, next });
|
|
717
|
+
if (model.report.env && model.report.env !== FLOW_KEY)
|
|
718
|
+
say(value(FLOW_KEY) ? `${FLOW_KEY} is set.` : `${FLOW_KEY}: not set yet (${RUN} setup writes it).`);
|
|
719
|
+
say(model.line);
|
|
720
|
+
say(account ? `Signed in as ${account.email}.` : current ? "Not signed in here (or flow.engineer could not be reached)." : "Not signed in.");
|
|
721
|
+
for (const c of CHANNELS) {
|
|
722
|
+
const { sender, open, trial } = channels[c];
|
|
723
|
+
const where = sender ? "in .env" : "not in .env";
|
|
724
|
+
const now = open === null ? "" : open ? "; open now" : "; not open yet";
|
|
725
|
+
say(`${CHANNEL_NAME[c]}: ${c === "telegram" ? `Flow Messaging key ${where}` : `sender ${where}`}${now}${trialNote(trial ?? undefined)}`);
|
|
726
|
+
}
|
|
727
|
+
if (next.length)
|
|
728
|
+
say(`Next: ${next.join(", then ")}`);
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* status's model line. Flow's key the active one: its credit from GET /credit. A key Flow does not
|
|
732
|
+
* know says so with the step that makes a new one; Flow not reachable leaves the credit unknown.
|
|
733
|
+
*/
|
|
734
|
+
async function flowModelStatus(value) {
|
|
735
|
+
const local = modelKeyReport(value, undefined, false);
|
|
736
|
+
if (local.report.env !== FLOW_KEY)
|
|
737
|
+
return local;
|
|
738
|
+
const key = value(FLOW_KEY);
|
|
739
|
+
try {
|
|
740
|
+
return modelKeyReport(value, await modelCredit(flowModelURL({ ...process.env, FLOW_MODEL_URL: value("FLOW_MODEL_URL") }), key), false);
|
|
741
|
+
}
|
|
742
|
+
catch (error) {
|
|
743
|
+
if (error instanceof LoginRefused && (error.status === 401 || error.status === 403)) {
|
|
744
|
+
const message = error.status === 401
|
|
745
|
+
? `${FLOW_KEY} is not a key Flow knows (revoked, or from another server).`
|
|
746
|
+
: `${FLOW_KEY} may not call Flow's models (its scopes lack model).`;
|
|
747
|
+
return { report: { ...local.report, message }, line: message, next: `${RUN} setup --rotate key` };
|
|
748
|
+
}
|
|
749
|
+
if (error instanceof LoginRefused && error.status === 402) {
|
|
750
|
+
const message = error.code === "trial_credit_used" ? USED_UP : NO_CREDIT;
|
|
751
|
+
return { report: { ...local.report, flow_credit: error.code === "trial_credit_used" ? { left_usd: 0 } : null, message }, line: message, next: OWN_KEY };
|
|
752
|
+
}
|
|
753
|
+
return { ...local, line: `${local.line} (credit left unknown: Flow could not be reached)` };
|
|
754
|
+
}
|
|
755
|
+
}
|
|
503
756
|
/** The names set in ./.env, with what the environment sets winning. */
|
|
504
757
|
function dotenvValue() {
|
|
505
758
|
const file = join(process.cwd(), ".env");
|
|
506
759
|
const saved = existsSync(file) ? envValues(readFileSync(file, "utf8").split("\n")) : new Map();
|
|
507
|
-
return (name) => process.env[name] || saved.get(name);
|
|
760
|
+
return (name) => process.env[name]?.trim() || saved.get(name);
|
|
508
761
|
}
|
|
509
762
|
/**
|
|
510
763
|
* The phone link for this folder's join code. Not set up yet (no join code or no Flow Messaging key
|
|
511
764
|
* in .env, or no sender for a WhatsApp or iMessage link): it signs in first when it has to (the same
|
|
512
765
|
* login, resuming a code already waiting) and runs setup, then asks flow.engineer for the channel's
|
|
513
|
-
* page
|
|
514
|
-
*
|
|
766
|
+
* page. A channel that is not open yet is said once, with the links of the channels that are (exit
|
|
767
|
+
* 0); with none open, it stops with the server's next step.
|
|
515
768
|
*/
|
|
516
769
|
async function link(options, json, out, say) {
|
|
517
770
|
const again = `${NEXT.link}${options.channel === "telegram" ? "" : ` --channel ${options.channel}`}`;
|
|
518
771
|
const what = CHANNEL_NAME[options.channel];
|
|
519
772
|
const needsSender = options.channel !== "telegram";
|
|
520
773
|
let value = dotenvValue();
|
|
521
|
-
let
|
|
774
|
+
let credit;
|
|
775
|
+
let had;
|
|
522
776
|
if (!value(SETUP_ENV.join_code) || !value(SETUP_ENV.messaging_key) || (needsSender && !value(SENDER_ENV[options.channel]))) {
|
|
523
777
|
agentFolder(process.cwd());
|
|
524
778
|
if (!(await signedInAccount(loginUrl()))) {
|
|
@@ -526,37 +780,90 @@ async function link(options, json, out, say) {
|
|
|
526
780
|
await login({ noBrowser: options.noBrowser, wait: false, noWait: false, force: true, timeoutSeconds: options.timeoutSeconds, resume: { then: again } }, json, out, say);
|
|
527
781
|
}
|
|
528
782
|
say("Setting up this agent's keys in .env.");
|
|
529
|
-
|
|
783
|
+
had = value;
|
|
784
|
+
({ answer: { model_credit: credit } } = await setupEnv({ replace: false, rotate: [], ci: false }, json, out, say, { channel: options.channel }));
|
|
530
785
|
value = dotenvValue();
|
|
531
|
-
restart = true;
|
|
532
786
|
}
|
|
533
787
|
const joinCode = value(SETUP_ENV.join_code);
|
|
534
788
|
if (!joinCode)
|
|
535
789
|
throw new Stop("no_join_code", `Setup wrote no ${SETUP_ENV.join_code} into .env.`, NEXT.setup, EXIT.refused);
|
|
536
|
-
if (needsSender && !value(SENDER_ENV[options.channel])) {
|
|
537
|
-
throw new Stop("channel_not_open", `${what} is not open on Flow's shared senders yet, so setup got no ${SENDER_ENV[options.channel]}.`, NEXT.link, EXIT.refused);
|
|
538
|
-
}
|
|
539
790
|
// The server's own try_url when setup kept one for this join code and channel, else the same page built here.
|
|
540
791
|
const fromServer = value(SETUP_ENV.try_url);
|
|
541
|
-
const
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
const
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
792
|
+
const name = agentDisplayName();
|
|
793
|
+
const pageFor = (channel) => namedPage(fromServer?.includes(`/try/${encodeURIComponent(joinCode)}?`) && URL.parse(fromServer)?.searchParams.get("c") === channel ? fromServer : tryLink(loginUrl(), joinCode, channel), name);
|
|
794
|
+
/** The channel's direct link, or the Stop that says it is not open (no sender in .env, or the try page says channel_not_open). */
|
|
795
|
+
const open = async (channel) => {
|
|
796
|
+
if (channel !== "telegram" && !value(SENDER_ENV[channel])) {
|
|
797
|
+
return new Stop("channel_not_open", `${CHANNEL_NAME[channel]} is not open on Flow's shared senders yet, so setup got no ${SENDER_ENV[channel]}.`, NEXT.link, EXIT.refused);
|
|
798
|
+
}
|
|
799
|
+
try {
|
|
800
|
+
return await directLink(pageFor(channel));
|
|
801
|
+
}
|
|
802
|
+
catch (error) {
|
|
803
|
+
if (error instanceof Stop && error.code === "channel_not_open")
|
|
804
|
+
return error;
|
|
805
|
+
throw error;
|
|
806
|
+
}
|
|
807
|
+
};
|
|
808
|
+
const model = modelKeyReport(value, credit, had !== undefined);
|
|
809
|
+
const asked = await open(options.channel);
|
|
810
|
+
let links;
|
|
811
|
+
let notOpen;
|
|
812
|
+
if (!(asked instanceof Stop))
|
|
813
|
+
links = [[options.channel, asked]];
|
|
814
|
+
else {
|
|
815
|
+
// The asked-for channel is closed: the others' links instead, each checked the same way.
|
|
816
|
+
links = [];
|
|
817
|
+
for (const c of CHANNELS.filter((c) => c !== options.channel)) {
|
|
818
|
+
const other = await open(c);
|
|
819
|
+
if (!(other instanceof Stop))
|
|
820
|
+
links.push([c, other]);
|
|
821
|
+
}
|
|
822
|
+
if (!links.length)
|
|
823
|
+
throw asked;
|
|
824
|
+
notOpen = `${what} isn't open yet; here ${links.length === 1 ? "is your" : "are your"} ${links.map(([c]) => CHANNEL_NAME[c]).join(" and ")} link${links.length === 1 ? "" : "s"}.`;
|
|
825
|
+
}
|
|
826
|
+
const [channel, first] = links[0];
|
|
827
|
+
const replyOnly = REPLY_ONLY[channel];
|
|
828
|
+
const sayToPerson = `${notOpen ? `${what} isn't open yet. ` : ""}Your ${CHANNEL_NAME[channel]} link: ${first.link} ${OPEN_HINT[channel]} `
|
|
829
|
+
+ `On a computer without ${CHANNEL_NAME[channel]}, open ${first.page} instead.${replyOnly ? ` ${replyOnly}` : ""}`;
|
|
830
|
+
if (json) {
|
|
831
|
+
out({
|
|
832
|
+
url: first.link, page: first.page, join_message: first.message, channel, join_code: joinCode, reply_only: Boolean(replyOnly), say_to_person: sayToPerson,
|
|
833
|
+
...(notOpen ? { not_open: options.channel, message: notOpen, links: Object.fromEntries(links.map(([c, d]) => [c, d.link])) } : {}),
|
|
834
|
+
model_key: model.report, ...(model.next ? { next: model.next } : {}),
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
if (notOpen) {
|
|
838
|
+
say(notOpen);
|
|
839
|
+
for (const [c, d] of links)
|
|
840
|
+
say(`On ${CHANNEL_NAME[c]}: ${d.link} (on a computer: ${d.page})`);
|
|
841
|
+
}
|
|
842
|
+
else {
|
|
843
|
+
say(first.link);
|
|
844
|
+
say(`On a computer without ${CHANNEL_NAME[channel]}: ${first.page}`);
|
|
845
|
+
}
|
|
846
|
+
if (model.report.set)
|
|
847
|
+
say(model.line);
|
|
848
|
+
const one = notOpen && links.length > 1 ? "one" : "it";
|
|
849
|
+
say(`${channel === "telegram" ? `Open ${one} on a phone and press Start` : `Open ${one} on a phone and send the message it fills in, "${first.message}"`}. From then on`);
|
|
850
|
+
// No model key: the agent cannot answer yet, so the phone does not chat with it until there is one.
|
|
851
|
+
say(model.report.set ? "that phone chats with this agent while it runs (npm start), with no public URL or tunnel." : "that phone reaches this agent while it runs (npm start), but it answers only once .env has a model key:");
|
|
852
|
+
if (model.next)
|
|
853
|
+
say(`${model.line}\nNext: ${model.next}.`);
|
|
854
|
+
for (const [c] of links)
|
|
855
|
+
if (REPLY_ONLY[c])
|
|
856
|
+
say(REPLY_ONLY[c]);
|
|
857
|
+
const added = had ? addedToRunning(had, value) : undefined;
|
|
858
|
+
if (added)
|
|
859
|
+
say(added);
|
|
554
860
|
}
|
|
555
861
|
/**
|
|
556
|
-
* Asks flow.engineer
|
|
557
|
-
* not know) answers {"error",
|
|
862
|
+
* Asks flow.engineer's /try page for its direct link as JSON ({"channel", "message", "link", "page"}):
|
|
863
|
+
* a channel it cannot open yet (or a join code it does not know) answers {"error",
|
|
864
|
+
* "error_description", "next"}, which stops here with that next step.
|
|
558
865
|
*/
|
|
559
|
-
async function
|
|
866
|
+
async function directLink(url) {
|
|
560
867
|
let response;
|
|
561
868
|
try {
|
|
562
869
|
response = await fetch(url, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
|
|
@@ -566,14 +873,18 @@ async function checkTryPage(url) {
|
|
|
566
873
|
throw new TimedOut(url, REQUEST_TIMEOUT_MS);
|
|
567
874
|
throw error;
|
|
568
875
|
}
|
|
569
|
-
if (response.ok)
|
|
570
|
-
return void (await response.body?.cancel());
|
|
571
876
|
const text = await response.text();
|
|
572
877
|
let body = {};
|
|
573
878
|
try {
|
|
574
879
|
body = JSON.parse(text);
|
|
575
880
|
}
|
|
576
881
|
catch { }
|
|
882
|
+
if (response.ok) {
|
|
883
|
+
const { link, message, page } = body;
|
|
884
|
+
if (typeof link === "string" && link && typeof message === "string" && message)
|
|
885
|
+
return { link, message, page: typeof page === "string" && page ? page : url };
|
|
886
|
+
throw new Stop("bad_answer", `${url} answered without a link.`, undefined, EXIT.refused);
|
|
887
|
+
}
|
|
577
888
|
const code = typeof body.error === "string" ? body.error : `http_${response.status}`;
|
|
578
889
|
const detail = typeof body.error_description === "string" ? body.error_description : `${url} answered ${response.status}.`;
|
|
579
890
|
throw new Stop(code, detail, typeof body.next === "string" && body.next ? body.next : undefined, EXIT.refused);
|