@flow-engineer/flow 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
1
  export declare const ACCOUNT_COMMANDS: Set<string>;
2
- export declare const ACCOUNT_LINES = " flow login [--wait | --no-wait] [--timeout <s>] [--force] [--no-browser] [--json]\n Sign in to flow.engineer: opens the browser, prints the link and code,\n and waits up to --timeout (default 120) for approval; --wait goes on\n waiting for the same code, --no-wait returns at once\n flow logout [--json] End this machine's sign-in and delete its token\n flow whoami [--json] Show the account you are signed in as\n flow setup [--replace] [--rotate <keys> [--now]] [--ci] [--json]\n Write this agent's keys (its Flow key FLOW_KEY, which also pays for the\n model on the account's trial credit when there is no provider key, test\n cloud, traces, Flow Messaging) and each open channel's sender into\n ./.env (in an agent's folder: package.json); --rotate key,cloud,traces,\n messaging or all makes those keys again (the old FLOW_KEY works 24 h\n more; --now ends it at once)\n flow link [--channel telegram|whatsapp|imessage] [--timeout <s>] [--no-browser] [--json]\n Print the link that opens a chat with this agent from a phone; signs in\n and runs setup first when this folder is not set up yet\n flow status [--json] What this folder has and lacks: FLOW_KEY and the model key (never their\n values) with the trial credit left, the\n sign-in, each channel's sender in .env, and, signed in, which channels\n are open now and their free-trial counts";
2
+ export declare const ACCOUNT_LINES = " flow login [--wait | --no-wait] [--timeout <s>] [--force] [--no-browser] [--json]\n Sign in to flow.engineer: opens the browser, prints the link and code,\n and waits up to --timeout (default 120) for approval; --wait goes on\n waiting for the same code, --no-wait returns at once\n flow logout [--json] End this machine's sign-in and delete its token\n flow whoami [--json] Show the account you are signed in as\n flow setup [--replace] [--rotate <keys> [--now]] [--display-name <name>] [--ci] [--json]\n Write this agent's keys (its Flow key FLOW_KEY, which also pays for the\n model on the account's trial credit when there is no provider key, test\n cloud, traces, Flow Messaging) and each open channel's sender into\n ./.env (in an agent's folder: package.json); --rotate key,cloud,traces,\n messaging or all makes those keys again (the old FLOW_KEY works 24 h\n more; --now ends it at once)\n flow link [--channel telegram|whatsapp|imessage] [--display-name <name>] [--timeout <s>] [--no-browser] [--json]\n Print the link that opens a chat with this agent from a phone; signs in\n and runs setup first when this folder is not set up yet. --display-name\n (here and on setup) renames the agent: package.json's displayName and\n its prompt, agent/AGENTS.md; the phone link then says \"Hi <name>\"\n flow status [--json] What this folder has and lacks: FLOW_KEY and the model key (never their\n values) with the trial credit left, the\n sign-in, each channel's sender in .env, and, signed in, which channels\n are open now and their free-trial counts";
3
3
  /** Runs one account command; resolves to the exit status. */
4
4
  export declare function accountCli(command: string, args: string[]): Promise<number>;
@@ -19,9 +19,10 @@ import { basename, join } from "node:path";
19
19
  import { parseArgs } from "node:util";
20
20
  import { setTimeout as delay } from "node:timers/promises";
21
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
- import { packageRoot } from "./new.js";
22
+ import { packageRoot, setDisplayName } from "./new.js";
23
23
  import { flowModelURL, hasModelKey, MODEL_KEY_NAMES, NO_MODEL_KEY } from "../../src/model.js";
24
24
  import { RUN } from "../../src/names.js";
25
+ import { runningAgent } from "../../src/local.js";
25
26
  export const ACCOUNT_COMMANDS = new Set(["login", "logout", "whoami", "setup", "link", "status"]);
26
27
  export const ACCOUNT_LINES = ` flow login [--wait | --no-wait] [--timeout <s>] [--force] [--no-browser] [--json]
27
28
  Sign in to flow.engineer: opens the browser, prints the link and code,
@@ -29,16 +30,18 @@ export const ACCOUNT_LINES = ` flow login [--wait | --no-wait] [--timeout <s>]
29
30
  waiting for the same code, --no-wait returns at once
30
31
  flow logout [--json] End this machine's sign-in and delete its token
31
32
  flow whoami [--json] Show the account you are signed in as
32
- flow setup [--replace] [--rotate <keys> [--now]] [--ci] [--json]
33
+ flow setup [--replace] [--rotate <keys> [--now]] [--display-name <name>] [--ci] [--json]
33
34
  Write this agent's keys (its Flow key FLOW_KEY, which also pays for the
34
35
  model on the account's trial credit when there is no provider key, test
35
36
  cloud, traces, Flow Messaging) and each open channel's sender into
36
37
  ./.env (in an agent's folder: package.json); --rotate key,cloud,traces,
37
38
  messaging or all makes those keys again (the old FLOW_KEY works 24 h
38
39
  more; --now ends it at once)
39
- flow link [--channel telegram|whatsapp|imessage] [--timeout <s>] [--no-browser] [--json]
40
+ flow link [--channel telegram|whatsapp|imessage] [--display-name <name>] [--timeout <s>] [--no-browser] [--json]
40
41
  Print the link that opens a chat with this agent from a phone; signs in
41
- and runs setup first when this folder is not set up yet
42
+ and runs setup first when this folder is not set up yet. --display-name
43
+ (here and on setup) renames the agent: package.json's displayName and
44
+ its prompt, agent/AGENTS.md; the phone link then says "Hi <name>"
42
45
  flow status [--json] What this folder has and lacks: FLOW_KEY and the model key (never their
43
46
  values) with the trial credit left, the
44
47
  sign-in, each channel's sender in .env, and, signed in, which channels
@@ -97,6 +100,7 @@ export async function accountCli(command, args) {
97
100
  : {}),
98
101
  ...(command === "setup" ? { replace: { type: "boolean", default: false }, rotate: { type: "string" }, now: { type: "boolean", default: false }, ci: { type: "boolean", default: false } } : {}),
99
102
  ...(command === "link" ? { channel: { type: "string", default: "telegram" } } : {}),
103
+ ...(command === "link" || command === "setup" ? { "display-name": { type: "string" } } : {}),
100
104
  },
101
105
  }));
102
106
  }
@@ -131,7 +135,23 @@ export async function accountCli(command, args) {
131
135
  // Straight to the file descriptors, never buffered: a coding agent's cut-off command keeps every line.
132
136
  const out = (report) => writeAll(1, `${JSON.stringify(report)}\n`);
133
137
  const say = (line) => writeAll(json ? 2 : 1, `${line}\n`);
138
+ let renamed = false;
134
139
  try {
140
+ if (typeof values["display-name"] === "string" && values.ci !== true) {
141
+ agentFolder(process.cwd());
142
+ const before = agentDisplayName();
143
+ let named;
144
+ try {
145
+ named = setDisplayName(process.cwd(), values["display-name"]);
146
+ }
147
+ catch (error) {
148
+ throw new Stop("bad_display_name", error.message, undefined, EXIT.usage);
149
+ }
150
+ renamed = named.displayName !== before || named.prompt;
151
+ if (json)
152
+ out({ display_name: named.displayName, prompt_renamed: named.prompt });
153
+ say(`People call it "${named.displayName}" now (package.json displayName${named.prompt ? " and agent/AGENTS.md" : "; agent/AGENTS.md named neither the old name nor Pip, so it is as it was"}).`);
154
+ }
135
155
  if (command === "login") {
136
156
  await login({ noBrowser: values["no-browser"] === true, wait: values.wait === true, noWait: values["no-wait"] === true, force: values.force === true, timeoutSeconds }, json, out, say);
137
157
  }
@@ -144,7 +164,7 @@ export async function accountCli(command, args) {
144
164
  else if (command === "setup")
145
165
  await setupEnv({ replace: values.replace === true, rotate, now: values.now === true, ci: values.ci === true }, json, out, say);
146
166
  else
147
- await link({ channel, noBrowser: values["no-browser"] === true, timeoutSeconds }, json, out, say);
167
+ await link({ channel, noBrowser: values["no-browser"] === true, timeoutSeconds, renamed }, json, out, say);
148
168
  return EXIT.ok;
149
169
  }
150
170
  catch (error) {
@@ -519,6 +539,16 @@ async function setupEnv(options, json, out, say, forLink) {
519
539
  // (and, for FLOW_KEY, ends) the old one rather than adding a second.
520
540
  const rotate = all ? true : rotated.size ? [...rotated] : undefined;
521
541
  const rotateNow = options.now === true && rotated.has("flow_key");
542
+ // FLOW_FRAMEWORK_TRY_URL is one channel's page: link's channel when it is open, else the server's (Telegram's).
543
+ const withTryUrl = (fields) => {
544
+ const code = fields.join_code;
545
+ if (!forLink || typeof fields[`${forLink.channel}_sender`] !== "string" || typeof code !== "string" || !code)
546
+ return fields;
547
+ const pages = fields.try_urls;
548
+ const page = typeof pages?.[forLink.channel] === "string" ? pages[forLink.channel] : tryLink(url, code, forLink.channel);
549
+ return { ...fields, try_url: page };
550
+ };
551
+ const chosenPage = (n) => forLink !== undefined && n === SETUP_ENV.try_url;
522
552
  let answer;
523
553
  try {
524
554
  // Every channel: each comes back with its shared sender only while it is open.
@@ -527,7 +557,7 @@ async function setupEnv(options, json, out, say, forLink) {
527
557
  catch (error) {
528
558
  // The keys minted anyway are not shown again, so whatever the error carries goes into .env first.
529
559
  if (error instanceof LoginRefused) {
530
- const written = writeEnv(file, lines, before, envEntries(error.fields), (n) => options.replace || rotatedEnv.has(n));
560
+ const written = writeEnv(file, lines, before, envEntries(withTryUrl(error.fields)), (n) => options.replace || rotatedEnv.has(n) || chosenPage(n));
531
561
  if (written.some((w) => w.action === "added" || w.action === "replaced"))
532
562
  say(`Kept in .env before the error: ${written.map((w) => `${w.name} ${w.action}`).join(", ")}.`);
533
563
  }
@@ -537,7 +567,7 @@ async function setupEnv(options, json, out, say, forLink) {
537
567
  const warnings = warningsOf(fields);
538
568
  const had = new Map(before);
539
569
  // --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)));
570
+ const written = writeEnv(file, lines, before, envEntries(withTryUrl(fields)), (n) => rotatedEnv.has(n) || (options.replace && !MODEL_KEY_NAMES.includes(n)) || chosenPage(n));
541
571
  if (typeof answer.join_code !== "string" || !answer.join_code) {
542
572
  const kept = written.filter((w) => w.action === "added" || w.action === "replaced").map((w) => w.name);
543
573
  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);
@@ -592,19 +622,25 @@ async function setupEnv(options, json, out, say, forLink) {
592
622
  // Said by link instead, once, with its link.
593
623
  if (!forLink)
594
624
  say(model.line);
595
- const people = trial?.imessage?.contacts;
596
- if (people && typeof people.used === "number" && typeof people.limit === "number")
597
- say(`iMessage: ${people.used} of ${people.limit} people`);
625
+ // link says its own channel's trial line instead.
598
626
  if (!forLink) {
627
+ const people = peopleLine(trial?.imessage);
628
+ if (people)
629
+ say(people);
599
630
  for (const c of open)
600
631
  say(`On ${CHANNEL_NAME[c]}: ${direct[c] ? `${direct[c].link} (on a computer: ${links[c]})` : links[c]}${trialNote(trial?.[c])}`);
601
632
  const added = addedToRunning((n) => had.get(n) || process.env[n]?.trim(), (n) => before.get(n) || process.env[n]?.trim());
602
633
  if (added)
603
- say(added);
634
+ say(`If npm start is running, restart it to add ${added}.`);
604
635
  say(`Next: ${next.join(", then ")}`);
605
636
  }
606
637
  return { answer };
607
638
  }
639
+ /** iMessage's count of the people it may reach on the trial, as a line; undefined when the answer had none. */
640
+ function peopleLine(t) {
641
+ const people = t?.contacts;
642
+ return people && typeof people.used === "number" && typeof people.limit === "number" ? `iMessage: ${people.used} of ${people.limit} people` : undefined;
643
+ }
608
644
  const trialNote = (t) => (t && typeof t.limit === "number" ? ` (free trial: ${t.used} of ${t.limit} messages used)` : "");
609
645
  /** An answer's field when it is an object (flow_key, model_credit), else undefined (an older server sends none). */
610
646
  function objectField(fields, name) {
@@ -658,8 +694,8 @@ function writeEnv(file, lines, before, entries, replace) {
658
694
  }
659
695
  /**
660
696
  * 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.
697
+ * `had` to `now`: the channels it is not on yet and a model key, as words ("Telegram and the model
698
+ * key"); undefined when nothing it uses is new.
663
699
  */
664
700
  function addedToRunning(had, now) {
665
701
  // The starter is on Telegram whenever it has the Flow Messaging key, and on WhatsApp and iMessage
@@ -670,7 +706,7 @@ function addedToRunning(had, now) {
670
706
  const what = [...channels, ...model];
671
707
  if (!what.length)
672
708
  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)}`}.`;
709
+ return `${what.length === 1 ? what[0] : `${what.slice(0, -1).join(", ")} and ${what.at(-1)}`}`;
674
710
  }
675
711
  /**
676
712
  * `flow status`: what this folder has and lacks, read from .env and the environment, and, when
@@ -772,6 +808,7 @@ async function link(options, json, out, say) {
772
808
  const needsSender = options.channel !== "telegram";
773
809
  let value = dotenvValue();
774
810
  let credit;
811
+ let trial;
775
812
  let had;
776
813
  if (!value(SETUP_ENV.join_code) || !value(SETUP_ENV.messaging_key) || (needsSender && !value(SENDER_ENV[options.channel]))) {
777
814
  agentFolder(process.cwd());
@@ -781,7 +818,7 @@ async function link(options, json, out, say) {
781
818
  }
782
819
  say("Setting up this agent's keys in .env.");
783
820
  had = value;
784
- ({ answer: { model_credit: credit } } = await setupEnv({ replace: false, rotate: [], ci: false }, json, out, say, { channel: options.channel }));
821
+ ({ answer: { model_credit: credit, trial } } = await setupEnv({ replace: false, rotate: [], ci: false }, json, out, say, { channel: options.channel }));
785
822
  value = dotenvValue();
786
823
  }
787
824
  const joinCode = value(SETUP_ENV.join_code);
@@ -827,11 +864,22 @@ async function link(options, json, out, say) {
827
864
  const replyOnly = REPLY_ONLY[channel];
828
865
  const sayToPerson = `${notOpen ? `${what} isn't open yet. ` : ""}Your ${CHANNEL_NAME[channel]} link: ${first.link} ${OPEN_HINT[channel]} `
829
866
  + `On a computer without ${CHANNEL_NAME[channel]}, open ${first.page} instead.${replyOnly ? ` ${replyOnly}` : ""}`;
867
+ const added = had ? addedToRunning(had, value) : undefined;
868
+ // npm start reads .env and its name once, when it starts. Not running: start it. Running (its
869
+ // .flow/running.json names a live process): restart it when setup wrote .env, the name changed
870
+ // just now or a model key is still to come, else it is ready as it is.
871
+ const running = runningAgent();
872
+ const runStep = !running
873
+ ? "npm start, then send the link above"
874
+ : had || options.renamed || model.next
875
+ ? `restart npm start (it runs at ${running.url}${added ? ` and lacks ${added}` : " and reads .env and its name only when it starts"}), then send the link above`
876
+ : `send the link above (npm start is running at ${running.url})`;
877
+ const next = [...(model.next ? [model.next] : []), runStep];
830
878
  if (json) {
831
879
  out({
832
880
  url: first.link, page: first.page, join_message: first.message, channel, join_code: joinCode, reply_only: Boolean(replyOnly), say_to_person: sayToPerson,
833
881
  ...(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 } : {}),
882
+ model_key: model.report, running: running ? { url: running.url } : false, next: next.join(", then "),
835
883
  });
836
884
  }
837
885
  if (notOpen) {
@@ -843,6 +891,18 @@ async function link(options, json, out, say) {
843
891
  say(first.link);
844
892
  say(`On a computer without ${CHANNEL_NAME[channel]}: ${first.page}`);
845
893
  }
894
+ // The trial's counts for the channel the link is on, when setup just gave them; no other channel's.
895
+ const counts = trial?.[channel];
896
+ if (counts) {
897
+ const note = trialNote(counts);
898
+ if (note)
899
+ say(`${CHANNEL_NAME[channel]} ${note.trim().slice(1, -1)}`); // "WhatsApp free trial: 3 of 300 messages used"
900
+ if (channel === "imessage") {
901
+ const people = peopleLine(counts);
902
+ if (people)
903
+ say(people);
904
+ }
905
+ }
846
906
  if (model.report.set)
847
907
  say(model.line);
848
908
  const one = notOpen && links.length > 1 ? "one" : "it";
@@ -850,13 +910,11 @@ async function link(options, json, out, say) {
850
910
  // No model key: the agent cannot answer yet, so the phone does not chat with it until there is one.
851
911
  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
912
  if (model.next)
853
- say(`${model.line}\nNext: ${model.next}.`);
913
+ say(model.line);
854
914
  for (const [c] of links)
855
915
  if (REPLY_ONLY[c])
856
916
  say(REPLY_ONLY[c]);
857
- const added = had ? addedToRunning(had, value) : undefined;
858
- if (added)
859
- say(added);
917
+ say(`Next: ${next.join(", then ")}.`);
860
918
  }
861
919
  /**
862
920
  * Asks flow.engineer's /try page for its direct link as JSON ({"channel", "message", "link", "page"}):
@@ -0,0 +1,42 @@
1
+ import type { Agent, Channel, EventSink, Incoming, Tool } from "../../src/index.ts";
2
+ import type { Command } from "./protocol.ts";
3
+ /** What a business run's agent must do: a Flow Framework Agent. */
4
+ export type BusinessAgent = Pick<Agent, "channels" | "channel" | "message" | "reacted" | "nudge" | "reach" | "runDue" | "conversation" | "fetch">;
5
+ /** What buildAgent is given for a business run, beside its tools and events. */
6
+ export interface BusinessBuild {
7
+ channels: Channel[];
8
+ testing: {
9
+ /** The run's clock. */
10
+ now: () => number;
11
+ /** No schedule timer: the clock moves only when the cloud says, and what came due runs then. */
12
+ scheduleCheckMs: 0;
13
+ /** The secret the run's events are signed with (Flow-Signature): made for the run, never a real one. */
14
+ eventsSecret: string;
15
+ };
16
+ }
17
+ /** One minute: the clock moves this far at a time, and what came due runs at each step. */
18
+ export declare const STEP_MS = 60000;
19
+ export interface BusinessContext {
20
+ /** The agent the session was opened with: its channels are copied for each run. */
21
+ sessionAgent: BusinessAgent;
22
+ build: (options: {
23
+ tools: Tool[];
24
+ events: EventSink[];
25
+ } & BusinessBuild) => BusinessAgent;
26
+ /** The tunnel's tools (each call asks the cloud), and the agent's own, for tools.real. */
27
+ tunnelTools: Tool[];
28
+ realTools: Tool[];
29
+ /** The session's event uploader. */
30
+ events: EventSink;
31
+ /** A POST to the session, sent again under `key` while it may be. */
32
+ post: (path: string, body: unknown, key: string, what: string) => Promise<unknown>;
33
+ /** Keeps a failure of fire-and-forget work as the session's (refusals allowed). */
34
+ watch: (work: Promise<unknown>, context: string, allowRefusal?: boolean) => void;
35
+ log: (line: string) => void;
36
+ }
37
+ export declare function businessRuns(ctx: BusinessContext): {
38
+ /** A business run's command, or a message or reaction for one: true when it was one. */
39
+ handle(command: Command, withFiles: (incoming: Extract<Command, {
40
+ type: "message";
41
+ }>["incoming"]) => Incoming): boolean;
42
+ };
@@ -0,0 +1,145 @@
1
+ // Business runs on the developer's side of the tunnel: scenarios the business starts (an event its
2
+ // backend sends, a nudge, time passing). Each run gets an agent of its own, built with a clock the
3
+ // cloud's commands move and with copies of the agent's own channels, so its sends go through what
4
+ // the agent wraps around them and come to the cloud with the run's time. The person writes through
5
+ // agent.message(), as a provider's webhook would, so they are a person the agent can nudge.
6
+ import { createHmac, randomBytes } from "node:crypto";
7
+ /** One minute: the clock moves this far at a time, and what came due runs at each step. */
8
+ export const STEP_MS = 60_000;
9
+ export function businessRuns(ctx) {
10
+ const runs = new Map();
11
+ const open = async (command) => {
12
+ const cloudId = command.conversationId;
13
+ const real = new Set(command.realTools ?? []);
14
+ const unknown = [...real].filter((name) => !ctx.realTools.some((t) => t.name === name));
15
+ if (unknown.length)
16
+ return { conversationId: cloudId, step: 0, at: command.now, error: `the agent has no tool ${unknown.join(", ")} to run for real` };
17
+ const run = { clock: command.now, secret: `whsec_test_${randomBytes(18).toString("hex")}`, address: command.address, channelName: command.channelName, conversationId: "", deliveries: new Set(), sent: 0 };
18
+ // The run's events and tool calls are the cloud's conversation, whatever the agent calls it.
19
+ const events = Object.assign((event) => ctx.events({ ...event, conversationId: cloudId }), { context: true });
20
+ const tools = ctx.tunnelTools.map((t) => {
21
+ if (real.has(t.name))
22
+ return ctx.realTools.find((r) => r.name === t.name);
23
+ return { ...t, run: (input, context) => t.run(input, { ...context, conversationId: cloudId }) };
24
+ });
25
+ const channels = ctx.sessionAgent.channels().map(({ name }) => {
26
+ const own = ctx.sessionAgent.channel(name);
27
+ return { ...own, send: (address, outbound) => deliver(run, cloudId, address, outbound) };
28
+ });
29
+ run.agent = ctx.build({ tools, events: [events], channels, testing: { now: () => run.clock, scheduleCheckMs: 0, eventsSecret: run.secret } });
30
+ run.conversationId = (await run.agent.reach({ channel: command.channelName, address: command.address })).conversationId;
31
+ runs.set(cloudId, run);
32
+ return { conversationId: cloudId, step: 0, at: run.clock };
33
+ };
34
+ const deliver = (run, cloudId, address, outbound) => {
35
+ if (address !== run.address) {
36
+ ctx.log(`${cloudId}: the agent sent to ${address}, not the run's person; the cloud does not see it`);
37
+ return Promise.resolve();
38
+ }
39
+ const body = { conversationId: cloudId, outbound, at: run.clock };
40
+ const delivery = ctx.post("outbound", body, `outbound:${cloudId}:b${++run.sent}`, "outbound delivery").then(() => { });
41
+ run.deliveries.add(delivery);
42
+ const done = () => void run.deliveries.delete(delivery);
43
+ delivery.then(done, done);
44
+ ctx.watch(delivery, "outbound delivery", true);
45
+ return delivery;
46
+ };
47
+ /** Until nothing more comes due now and everything set off has been sent. */
48
+ const settle = async (run) => {
49
+ for (let i = 0; i < 10; i++) {
50
+ const due = await run.agent.runDue();
51
+ await run.agent.conversation(run.conversationId)?.whenIdle();
52
+ await Promise.allSettled([...run.deliveries]);
53
+ if (!due && !run.agent.conversation(run.conversationId)?.busy)
54
+ return;
55
+ }
56
+ };
57
+ const event = async (run, command) => {
58
+ const body = JSON.stringify(command.envelope);
59
+ const t = Math.floor(run.clock / 1000);
60
+ const secret = command.signature === "bad" ? `whsec_test_${randomBytes(18).toString("hex")}` : run.secret;
61
+ const headers = { "content-type": "application/json", "idempotency-key": command.envelope.id };
62
+ if (command.signature !== "missing")
63
+ headers["flow-signature"] = `t=${t},v1=${createHmac("sha256", secret).update(`t.${t}.${body}`).digest("hex")}`;
64
+ const response = await run.agent.fetch(new Request(`http://agent.test${command.path ?? "/v1/events"}`, { method: "POST", headers, body }));
65
+ const text = await response.text();
66
+ let parsed = text;
67
+ try {
68
+ parsed = JSON.parse(text);
69
+ }
70
+ catch { /* not JSON: kept as text */ }
71
+ return { answer: { status: response.status, body: parsed } };
72
+ };
73
+ const step = async (run, command) => {
74
+ const base = { conversationId: command.conversationId, step: command.step };
75
+ let result = {};
76
+ try {
77
+ if (command.type === "event")
78
+ result = await event(run, command);
79
+ else if (command.type === "nudge") {
80
+ const { expiresAt, ...rest } = command.nudge;
81
+ const nudged = await run.agent.nudge({ to: { channel: run.channelName, address: run.address }, ...rest, ...(expiresAt !== undefined ? { expiresAt } : {}) });
82
+ result = { nudge: { status: nudged.status, ...(nudged.reason ? { reason: nudged.reason } : {}) } };
83
+ }
84
+ else {
85
+ if (command.to < run.clock)
86
+ throw new Error("the clock never moves back");
87
+ while (run.clock < command.to) {
88
+ run.clock = Math.min(command.to, run.clock + STEP_MS);
89
+ await settle(run);
90
+ }
91
+ }
92
+ await settle(run);
93
+ }
94
+ catch (error) {
95
+ result = { error: error instanceof Error ? error.message : String(error) };
96
+ }
97
+ return { ...base, ...result, at: run.clock };
98
+ };
99
+ return {
100
+ /** A business run's command, or a message or reaction for one: true when it was one. */
101
+ handle(command, withFiles) {
102
+ // Steps run beside the stream, so one run's long wait never holds up another's commands; the cloud
103
+ // sends a run's next command only after this one's answer.
104
+ if (command.type === "open") {
105
+ ctx.watch((async () => {
106
+ const answer = await open(command).catch((error) => ({ conversationId: command.conversationId, step: 0, at: command.now, error: error instanceof Error ? error.message : String(error) }));
107
+ await ctx.post("steps", answer, `step:${command.conversationId}:0`, "step answer");
108
+ })(), "business step");
109
+ return true;
110
+ }
111
+ if (command.type === "close") {
112
+ runs.delete(command.conversationId);
113
+ return true;
114
+ }
115
+ if (command.type === "event" || command.type === "nudge" || command.type === "advance") {
116
+ ctx.watch((async () => {
117
+ const run = runs.get(command.conversationId);
118
+ const answer = run ? await step(run, command) : { conversationId: command.conversationId, step: command.step, at: 0, error: "no such run open" };
119
+ await ctx.post("steps", answer, `step:${command.conversationId}:${command.step}`, "step answer");
120
+ })(), "business step");
121
+ return true;
122
+ }
123
+ if (command.type !== "message" && command.type !== "reaction")
124
+ return false;
125
+ const run = runs.get(command.conversationId);
126
+ if (!run)
127
+ return false;
128
+ if (command.type === "reaction") {
129
+ run.agent.reacted(run.conversationId, command.emoji, command.toText);
130
+ return true;
131
+ }
132
+ // As a provider's webhook would: the person by their address on the run's channel.
133
+ const work = (async () => {
134
+ const taken = await run.agent.message({ channel: run.channelName, from: run.address, message: withFiles(command.incoming), messageId: command.messageId });
135
+ // An intercepted message's reply is out before its receipt, as on the other runs.
136
+ if (!taken)
137
+ await Promise.allSettled([...run.deliveries]);
138
+ const receipt = { conversationId: command.conversationId, messageId: command.messageId, status: taken ? "taken" : "intercepted" };
139
+ await ctx.post("receipts", receipt, `receipt:${receipt.conversationId}:${receipt.messageId}`, "delivery receipt");
140
+ })();
141
+ ctx.watch(work, "delivery receipt");
142
+ return true;
143
+ },
144
+ };
145
+ }
@@ -32,7 +32,7 @@ import { request as httpsRequest } from "node:https";
32
32
  import { basename, dirname, join } from "node:path";
33
33
  import { pathToFileURL } from "node:url";
34
34
  import { parseArgs } from "node:util";
35
- import { hasModelKey, NO_MODEL_KEY } from "../../src/model.js";
35
+ import { hasModelKey } from "../../src/model.js";
36
36
  import { PLUGIN_LINE, pluginCli } from "./plugin.js";
37
37
  import { OBSERVE_URL, setting } from "../../src/traces.js";
38
38
  import { cloudHeaders, runInCloud, SessionStopped } from "./client.js";
@@ -122,10 +122,11 @@ const USAGE = `Usage:\n${NEW_LINE}\n${CHAT_LINE}\n${TEST_LINE}\n${SHOW_LINE}\n${
122
122
  ${PLUGIN_LINE}\n${ACCOUNT_LINES}\n flow --version Show the version\n\n${TEST_OPTIONS}`;
123
123
  /** What to do after new or init, from step 2; a model key comes first when none is set here. */
124
124
  function nextSteps() {
125
+ // link signs in and writes the keys first, FLOW_KEY (the model, on Flow's free trial credit) among
126
+ // them, so npm start comes after it and has the model key and the channels from its first start.
125
127
  const steps = [
126
- ...(hasModelKey(process.env) ? [] : [`Model key: none yet. ${NO_MODEL_KEY.nextLine.replace(/^Next: r/, "R")} (in .env: cp .env.example .env; the file says where to make one)`]),
127
- `npm start, and open the link it prints: try it in the playground as its test people, channels side by side`,
128
- `npx @flow-engineer/flow link: signs in, writes the other keys, prints the link for your phone;\n keep npm start running and text it`,
128
+ `npx @flow-engineer/flow link --channel telegram (or whatsapp, imessage): signs in, writes the keys\n into .env (FLOW_KEY pays for the model on Flow's free trial credit; ${hasModelKey(process.env) ? "your own model key in .env still wins" : "or put your own model key in .env"}) and prints the link for your phone`,
129
+ `npm start, and open the link it prints: try it in the playground as its test people, channels side by side;\n keep it running and text it from your phone`,
129
130
  `Tell your coding agent what to build: its skills are in .claude/skills/`,
130
131
  ];
131
132
  return steps.map((step, i) => ` ${i + 2}. ${step}`).join("\n");
@@ -203,7 +204,7 @@ async function askDisplayName(given, packageName) {
203
204
  }
204
205
  const namedLine = (dir) => {
205
206
  const displayName = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).displayName;
206
- return `People call it "${displayName}" (package.json displayName): a phone reaches it with "Hi ${displayName} (ref <code>)".`;
207
+ return `People call it "${displayName}" (package.json displayName, and its prompt agent/AGENTS.md): a phone reaches it with "Hi ${displayName} (ref <code>)".`;
207
208
  };
208
209
  const displayNameFlag = values["display-name"];
209
210
  if (command === "new") {
@@ -218,7 +219,7 @@ if (command === "new") {
218
219
  console.error(error.message);
219
220
  process.exit(1);
220
221
  }
221
- console.log(`Made ./${name}/ from the café example (Pip). ${namedLine(dir)} Next:
222
+ console.log(`Made ./${name}/ from the café example. ${namedLine(dir)} Next:
222
223
  1. cd ${name} && npm install
223
224
  ${nextSteps()}`);
224
225
  process.exit(0);
@@ -235,7 +236,7 @@ if (command === "init") {
235
236
  process.exit(1);
236
237
  }
237
238
  loadEnv(undefined); // a .env already here may hold a model key
238
- console.log(`Made ${name}, started from the café example (Pip), in this folder. ${namedLine(process.cwd())} Next:
239
+ console.log(`Made ${name}, started from the café example, in this folder. ${namedLine(process.cwd())} Next:
239
240
  1. npm install
240
241
  ${nextSteps()}`);
241
242
  process.exit(0);
@@ -303,7 +304,8 @@ else {
303
304
  if (typeof module.buildAgent !== "function" || !Array.isArray(module.tools)) {
304
305
  invalid("src/agent.ts must export buildAgent({ model, events, tools }) and tools, or pass --agent-url to test an agent by its HTTP endpoint.");
305
306
  }
306
- agent = { tools: module.tools, build: ({ tools, events }) => module.buildAgent({ tools, events }) };
307
+ // A business run's agent also gets its channels and clock (testing/src/business.ts); buildAgent passes them on to createAgent.
308
+ agent = { tools: module.tools, build: ({ tools, events, channels, testing }) => module.buildAgent({ tools, events, ...(channels ? { channels, testing } : {}) }) };
307
309
  }
308
310
  /** With --agent-url the agent's own tools run for real: the suite's tool simulators do not apply. */
309
311
  function forUrl(files) {
@@ -1,6 +1,7 @@
1
1
  import { type Agent, type EventSink, type Tool } from "../../src/index.ts";
2
2
  import type { Opened, RunSummary, SessionSummary } from "./protocol.ts";
3
3
  import { type SuiteFile } from "./suite.ts";
4
+ import { type BusinessBuild } from "./business.ts";
4
5
  export interface ConnectOptions {
5
6
  /** The cloud's address, such as http://127.0.0.1:8900. */
6
7
  cloud: string;
@@ -10,11 +11,12 @@ export interface ConnectOptions {
10
11
  title?: string;
11
12
  /** The agent's real tools: only their names, descriptions and inputs are used. */
12
13
  tools: Tool[];
13
- /** Builds the agent with the tools and events it is given: a Flow Framework Agent, or anything that answers like one (http-agent.ts). */
14
+ /** Builds the agent with the tools and events it is given: a Flow Framework Agent, or anything that answers like one (http-agent.ts).
15
+ * A business run (protocol.ts) builds one more per run, with its own channels and clock (`BusinessBuild`); that needs a Flow Framework Agent. */
14
16
  build: (options: {
15
17
  tools: Tool[];
16
18
  events: EventSink[];
17
- }) => AgentUnderTest;
19
+ } & Partial<BusinessBuild>) => AgentUnderTest;
18
20
  files: SuiteFile[];
19
21
  /** The tests folder: files a scenario attaches are read from here, and never leave this machine. */
20
22
  testsDir?: string;
@@ -10,6 +10,7 @@ import { toSpec } from "../../src/index.js";
10
10
  import { PROFILE_TOOL, profileTool } from "../../src/conversation.js";
11
11
  import { STAND_IN } from "../../src/tools.js";
12
12
  import { mimeOf, parseScenario, parseToolFile, suiteHash } from "./suite.js";
13
+ import { businessRuns } from "./business.js";
13
14
  /** The session opened, then the cloud ended it with a `failed` command. `code` is the reason's leading
14
15
  * code (`capacity: ...` gives capacity), when it starts with one. */
15
16
  export class CloudEnded extends Error {
@@ -351,6 +352,8 @@ async function playSession(options, only) {
351
352
  events.context = true;
352
353
  try {
353
354
  const agent = options.build({ tools, events: [events] });
355
+ // Business runs need an agent of this framework: its own channels, routes, clock and nudges.
356
+ const canBusiness = typeof agent.reach === "function" && typeof agent.fetch === "function";
354
357
  const open = {
355
358
  agent: { name: options.name, title: options.title, tools: [...options.tools, ...(agent.hasProfile?.() ? standIn : [])].map(toSpec), channels: agent.channels() },
356
359
  files: options.files.map(({ path, hash }) => ({ path, hash })),
@@ -359,6 +362,7 @@ async function playSession(options, only) {
359
362
  retries: true,
360
363
  resume: true,
361
364
  reopen: true,
365
+ ...(canBusiness ? { business: true } : {}),
362
366
  ...(only ? { only } : {}),
363
367
  };
364
368
  const opened = await openOnce(`${base}/api/sessions`, open, options.openHeaders, controller.signal, postTimeoutMs, openRetryCapMs, log);
@@ -382,6 +386,20 @@ async function playSession(options, only) {
382
386
  options.onDashboard?.(dashboard, opened.sessionId, typeof opened.agent === "string" && opened.agent ? opened.agent : undefined);
383
387
  log(`Watch it: ${opened.dashboard}`);
384
388
  session = `${tunnel}/api/sessions/${opened.sessionId}`;
389
+ const business = canBusiness && opened.business === true
390
+ ? businessRuns({
391
+ sessionAgent: agent,
392
+ build: (built) => options.build(built),
393
+ tunnelTools: tools,
394
+ realTools: options.tools,
395
+ events,
396
+ post: (path, body, key, what) => postRetry(`${session}/${path}`, body, auth, controller.signal, log, postTimeoutMs, {
397
+ mode: idempotent ? "idempotent" : path === "outbound" ? "outbound" : "never", key, budgetMs: retryBudgetMs, what,
398
+ }),
399
+ watch,
400
+ log,
401
+ })
402
+ : null;
385
403
  if (failure)
386
404
  throw failure;
387
405
  startFlush();
@@ -447,6 +465,8 @@ async function playSession(options, only) {
447
465
  const handle = async (command) => {
448
466
  if (draining && (command.type === "message" || command.type === "reaction"))
449
467
  throw new Error("the cloud sent a command after drain");
468
+ if (business && (business.handle(command, (incoming) => withFiles(incoming, options.testsDir, log))))
469
+ return;
450
470
  if (command.type === "message") {
451
471
  const deliveries = [];
452
472
  // Not async: register the promise during receive(), before its fire-and-forget
@@ -1,3 +1,3 @@
1
1
  export { runInCloud, type CloudSummary, type ConnectOptions } from "./client.ts";
2
- export { hashOf, loadSuite, parseScenario, parseToolFile, suiteHash, type Scenario, type SuiteFile, type ToolFile } from "./suite.ts";
3
- export type { Check, Command, OpenSession, Opened, RunKey, RunSummary, SessionSummary, ToolCallRequest, ToolCallResult, UploadFile, Usage, WireIncoming } from "./protocol.ts";
2
+ export { hashOf, isBusiness, loadSuite, parseScenario, parseToolFile, suiteHash, waitMs, type OpeningStep, type Scenario, type SentItem, type SuiteFile, type ToolFile } from "./suite.ts";
3
+ export type { BusinessCommand, BusinessNudge, BusinessOutbound, Check, Command, FlowEventEnvelope, StepAnswer, OpenSession, Opened, RunKey, RunSummary, SessionSummary, ToolCallRequest, ToolCallResult, UploadFile, Usage, WireIncoming } from "./protocol.ts";
@@ -1,2 +1,2 @@
1
1
  export { runInCloud } from "./client.js";
2
- export { hashOf, loadSuite, parseScenario, parseToolFile, suiteHash } from "./suite.js";
2
+ export { hashOf, isBusiness, loadSuite, parseScenario, parseToolFile, suiteHash, waitMs } from "./suite.js";
@@ -6,6 +6,15 @@ export declare function packageRoot(from?: string): string;
6
6
  export declare const MAX_DISPLAY_NAME = 60;
7
7
  /** The name people call the agent, from the package name when none was given: "my-shop" is "my shop". */
8
8
  export declare function displayNameOf(given: string | undefined, packageName: string): string;
9
+ /**
10
+ * Renames the agent in `dir`: package.json's displayName, and in its prompt the old display name
11
+ * (and the café's Pip) become the new one. Returns the name and whether the prompt changed (a prompt
12
+ * rewritten by hand may name neither).
13
+ */
14
+ export declare function setDisplayName(dir: string, given: string): {
15
+ displayName: string;
16
+ prompt: boolean;
17
+ };
9
18
  interface MakeOptions {
10
19
  root?: string;
11
20
  /** What people call the agent; the package name in words when not given. */