@flow-engineer/flow 0.2.3 → 0.3.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.
Files changed (40) hide show
  1. package/README.md +3 -3
  2. package/coding-agents/skill.md +24 -9
  3. package/coding-agents/test-skill.md +50 -1
  4. package/dist/flow-messaging/src/index.js +8 -5
  5. package/dist/simulators/src/server.d.ts +2 -0
  6. package/dist/simulators/src/server.js +7 -1
  7. package/dist/src/agent.d.ts +32 -0
  8. package/dist/src/agent.js +38 -8
  9. package/dist/src/channels.d.ts +2 -0
  10. package/dist/src/channels.js +1 -0
  11. package/dist/src/conversation.d.ts +2 -0
  12. package/dist/src/conversation.js +8 -2
  13. package/dist/src/http.js +5 -2
  14. package/dist/src/index.d.ts +1 -0
  15. package/dist/src/index.js +1 -0
  16. package/dist/src/local.d.ts +22 -0
  17. package/dist/src/local.js +85 -0
  18. package/dist/src/types.d.ts +6 -0
  19. package/dist/testing/src/account.d.ts +1 -1
  20. package/dist/testing/src/account.js +107 -29
  21. package/dist/testing/src/business.d.ts +42 -0
  22. package/dist/testing/src/business.js +145 -0
  23. package/dist/testing/src/cli.js +10 -8
  24. package/dist/testing/src/client.d.ts +4 -2
  25. package/dist/testing/src/client.js +20 -0
  26. package/dist/testing/src/index.d.ts +2 -2
  27. package/dist/testing/src/index.js +1 -1
  28. package/dist/testing/src/new.d.ts +9 -0
  29. package/dist/testing/src/new.js +44 -5
  30. package/dist/testing/src/protocol.d.ts +99 -1
  31. package/dist/testing/src/protocol.js +9 -0
  32. package/dist/testing/src/suite.d.ts +142 -3
  33. package/dist/testing/src/suite.js +94 -10
  34. package/docs/agents.md +40 -10
  35. package/examples/cafe/src/agent.ts +3 -3
  36. package/examples/cafe/src/channels.ts +1 -1
  37. package/examples/cafe/src/main.ts +23 -8
  38. package/examples/cafe/src/mcp.ts +24 -6
  39. package/examples/cafe/src/tools.ts +1 -1
  40. package/package.json +1 -1
@@ -87,6 +87,87 @@ const expectedCall = z.union([
87
87
  args: withClock(z.record(z.string(), z.unknown())).optional(),
88
88
  }),
89
89
  ], { error: "a tool name, or { tool: <name>, args: { <field>: <value> } }" });
90
+ /** "8h30m", "2d", "45m": how long a `wait` step lets pass. */
91
+ const WAIT = /^(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?$/;
92
+ /** A wait's length in ms, or null when it is not one. */
93
+ export function waitMs(text) {
94
+ const m = WAIT.exec(text.trim());
95
+ if (!m || !(m[1] || m[2] || m[3]))
96
+ return null;
97
+ const ms = (Number(m[1] ?? 0) * 24 * 60 + Number(m[2] ?? 0) * 60 + Number(m[3] ?? 0)) * 60_000;
98
+ return ms > 0 ? ms : null;
99
+ }
100
+ /** A moment on the test's clock: a date and time (local to the scenario's timezone unless it has an offset),
101
+ * HH:MM (the next time the clock shows it), or a `{{now ...}}` placeholder. */
102
+ const moment = z.string().refine((t) => isDateTime(t) || /^([01]\d|2[0-3]):[0-5]\d$/.test(t) || (/^\s*\{\{[\s\S]*\}\}\s*$/.test(t) && placeholderProblem(t) === null), "a time on the test's clock: 2026-10-20T23:30, 2026-10-20T23:30+05:30, 23:30, or {{now + 1d @ 08:00}}");
103
+ const template = z.strictObject({
104
+ name: z.string().min(1),
105
+ language: z.string().optional(),
106
+ body: z.array(z.string()).optional(),
107
+ buttons: z.array(z.strictObject({ index: z.number().int().min(0), value: z.string() })).optional(),
108
+ });
109
+ /** A Flow event as the business's backend sends it; `id` and `occurredAt` default to one per step and the clock. */
110
+ const flowEvent = withClock(z.strictObject({
111
+ id: z.string().min(1).optional(),
112
+ type: z.string().min(1),
113
+ occurredAt: z.string().optional(),
114
+ subject: z.string().min(1),
115
+ delivery: z.string().optional(),
116
+ data: z.record(z.string(), z.unknown()),
117
+ }));
118
+ const businessNudge = withClock(z
119
+ .strictObject({
120
+ text: z.string().min(1).optional(),
121
+ prompt: z.string().min(1).optional(),
122
+ template: template.optional(),
123
+ respectQuietHours: z.boolean().optional(),
124
+ expiresAt: moment.optional(),
125
+ })
126
+ .refine((n) => [n.text, n.prompt, n.template].filter((v) => v !== undefined).length === 1, "exactly one of text, prompt or template"));
127
+ /** What the agent's route must answer an event: a status, or a status and the field a 400 names. */
128
+ const answer = z.union([z.number().int().min(100).max(599), z.strictObject({ status: z.number().int().min(100).max(599), field: z.string().min(1).optional() })]);
129
+ /** One thing the agent sent (`expect.sent`), or must not have (`expect.not_sent`; `{}` is anything at all). */
130
+ const sentItem = z.strictObject({
131
+ /** When, on the test's clock: HH:MM (any day), or a date and time; to the minute. */
132
+ at: moment.optional(),
133
+ /** A template by name. */
134
+ template: z.string().min(1).optional(),
135
+ /** The agent's own words rather than a template. */
136
+ phrased: z.literal(true).optional(),
137
+ /** Text the message (or the template's values) holds. */
138
+ text: withClock(z.string().min(1)).optional(),
139
+ });
140
+ const openingStep = z
141
+ .object({
142
+ say: withClock(z.string()).optional(),
143
+ /** A file the person sends with it (say is its caption): a path in the tests folder. */
144
+ attach: z.object({ kind: z.enum(["image", "audio", "document"]), file: z.string().refine(inTests, "a path inside the tests folder, such as fixtures/receipt.jpg") }).optional(),
145
+ after_ms: z.number().int().min(0).optional(),
146
+ /** The business's backend sends this event, to the agent's own route. */
147
+ event: flowEvent.optional(),
148
+ /** The route the event goes to; /v1/events when left out. */
149
+ path: z.string().regex(/^\/[^\s?#]*$/, "a path such as /v1/events").optional(),
150
+ /** bad: signed with another secret; missing: no Flow-Signature header. */
151
+ signature: z.enum(["bad", "missing"]).optional(),
152
+ /** What the route must answer; anything 2xx when left out. */
153
+ answer: answer.optional(),
154
+ /** The business nudges the person. */
155
+ nudge: businessNudge.optional(),
156
+ /** Time passes on the test's clock. */
157
+ wait: z.string().refine((t) => waitMs(t) !== null, 'a length of time such as 8h30m, 45m or 2d').optional(),
158
+ /** Move the test's clock to this time before the step. */
159
+ at: moment.optional(),
160
+ })
161
+ .superRefine((step, ctx) => {
162
+ const kinds = [step.say !== undefined || step.attach ? "say" : null, step.event ? "event" : null, step.nudge ? "nudge" : null, step.wait !== undefined ? "wait" : null].filter(Boolean);
163
+ if (kinds.length !== 1)
164
+ ctx.addIssue({ code: "custom", message: kinds.length ? `one of say, event, nudge or wait per step, not ${kinds.join(" and ")}` : "say something, attach a file, send an event, nudge, or wait" });
165
+ if (!step.event && (step.path || step.signature || step.answer !== undefined))
166
+ ctx.addIssue({ code: "custom", message: "path, signature and answer belong to an event step" });
167
+ if (kinds[0] === "say" && !step.say && !step.attach)
168
+ ctx.addIssue({ code: "custom", message: "say something, attach a file, or both" });
169
+ })
170
+ .transform((step) => (step.event || step.nudge || step.wait !== undefined ? step : { ...step, say: step.say ?? "" }));
90
171
  const scenarioSchema = z.object({
91
172
  id: z.string().min(1),
92
173
  title: z.string().optional(),
@@ -100,20 +181,15 @@ const scenarioSchema = z.object({
100
181
  .preprocess((v) => (v instanceof Date ? v.toISOString().replace(/\.\d+Z$/, "Z") : v), z.string().refine(isDateTime, "not a date and time: write YYYY-MM-DDTHH:MM, with :SS, Z or an offset such as -04:00 if needed"))
101
182
  .optional(),
102
183
  person: z.object({
184
+ /** Their address on the run's channel (a phone number on phone channels): who a business run's events and nudges are for. */
185
+ address: z.string().min(1).optional(),
103
186
  persona: withClock(z.string()),
104
187
  goal: withClock(z.string()),
105
188
  knows: z.array(withClock(z.string())).optional(),
106
189
  }),
107
- opening: z
108
- .array(z
109
- .object({
110
- say: withClock(z.string()).default(""),
111
- /** A file the person sends with it (say is its caption): a path in the tests folder. */
112
- attach: z.object({ kind: z.enum(["image", "audio", "document"]), file: z.string().refine(inTests, "a path inside the tests folder, such as fixtures/receipt.jpg") }).optional(),
113
- after_ms: z.number().int().min(0).optional(),
114
- })
115
- .refine((step) => step.say || step.attach, "say something, attach a file, or both"))
116
- .min(1),
190
+ opening: z.array(openingStep).min(1),
191
+ /** Tools whose own code runs in this scenario, in place of the cloud's simulation (state the agent keeps itself). */
192
+ tools: z.strictObject({ real: z.array(z.string().min(1)).min(1) }).optional(),
117
193
  world: withClock(z.string()).optional(),
118
194
  max_turns: z.number().int().min(1).max(50).default(8),
119
195
  expect: z
@@ -124,6 +200,10 @@ const scenarioSchema = z.object({
124
200
  calls: z.array(expectedCall).optional(),
125
201
  /** None may be called. */
126
202
  never: z.array(expectedCall).optional(),
203
+ /** What the agent sent, in order (other sends may come between): checked by code, with the test's clock. */
204
+ sent: z.array(sentItem).optional(),
205
+ /** None of these was sent; `{}` is anything at all. */
206
+ not_sent: z.array(sentItem).optional(),
127
207
  })
128
208
  .default({}),
129
209
  });
@@ -149,6 +229,10 @@ export function mimeOf(path) {
149
229
  const types = { jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", webp: "image/webp", gif: "image/gif", pdf: "application/pdf", ogg: "audio/ogg", opus: "audio/ogg", mp3: "audio/mpeg", m4a: "audio/mp4", wav: "audio/wav" };
150
230
  return types[extname(path).slice(1).toLowerCase()] ?? "application/octet-stream";
151
231
  }
232
+ /** The business starts something in it (an event, a nudge, time passing), or it checks what was sent: a business run. */
233
+ export function isBusiness(scenario) {
234
+ return scenario.opening.some((s) => s.event || s.nudge || s.wait !== undefined || s.at !== undefined) || !!scenario.expect.sent || !!scenario.expect.not_sent || !!scenario.tools;
235
+ }
152
236
  /** Parse and check a scenario. The error has one line per problem: `file:line: field: what is wrong`. */
153
237
  export function parseScenario(file) {
154
238
  return parseFile(scenarioSchema, file);
package/docs/agents.md CHANGED
@@ -15,16 +15,24 @@ package.
15
15
  ```sh
16
16
  npx @flow-engineer/flow init --display-name "FlatX agent" # the starter in this (empty) folder; ask the owner what to call it first
17
17
  npm install
18
- npx @flow-engineer/flow link --json # signs in and sets up if needed (the keys, Flow's trial model key among them), then prints the phone link
19
- npm start # keep it running: the playground link it prints, and the phone link reaches it
18
+ npx @flow-engineer/flow link --channel telegram --json # or whatsapp, imessage: signs in and sets up if needed (the keys, Flow's trial model key among them), then prints the phone link
19
+ npm start # after link: keep it running; the playground link it prints, and the phone link reaches it
20
20
  npx @flow-engineer/flow status --json # any time: what the folder has and lacks
21
21
  printf 'hi\nA latte please\n' | npx @flow-engineer/flow chat # your own check: talk to it locally, one message per line
22
22
  ```
23
23
 
24
24
  Before `init` (or `flow new <folder>`), ask the owner what people should call their agent, such as
25
25
  "FlatX agent", and pass it as `--display-name`. It is kept as `displayName` in `package.json`, and
26
- the message their phone sends to reach the agent is `Hi FlatX agent (ref <code>)`; change it there
27
- any time. Without the flag, `init` asks in a terminal and otherwise uses the folder's name in words.
26
+ the message their phone sends to reach the agent is `Hi FlatX agent (ref <code>)`. The name takes the
27
+ starter's (Pip) place in the agent's prompt, `agent/AGENTS.md`, and the playground and MCP server
28
+ read it from `package.json`. To rename an agent made already, `link --display-name "<name>"` (or
29
+ `setup --display-name`) changes both; restart `npm start` after it. Without the flag, `init` asks in
30
+ a terminal and otherwise uses the folder's name in words.
31
+
32
+ `npm start` listens on `PORT`, else 8788: with 8788 taken and no `PORT` set it takes the next free
33
+ port and says so, and the playground link it prints has the port it uses. With `PORT` set to a port
34
+ that is taken it prints one line, `Port 8788 is in use. Run: PORT=8798 npm start`, and exits 1.
35
+ `npm run mcp` does the same with `MCP_PORT` (else 8790).
28
36
 
29
37
  ## How the owner tests it
30
38
 
@@ -64,20 +72,33 @@ before you hand it back; run them yourself, and do not offer them to the owner a
64
72
  - `link` does the rest when the folder is not set up (no `FLOW_FRAMEWORK_JOIN_CODE` or
65
73
  `FLOW_MESSAGING_KEY` in `.env`, or no sender for the WhatsApp or iMessage link asked for): it
66
74
  runs `login` (opens the owner's browser itself and prints the link and code at once), then
67
- `setup` (writes the keys and each open channel's sender into `.env`), then prints the link.
75
+ `setup` (writes the keys and each open channel's sender into `.env`, and the chosen channel's
76
+ page as `FLOW_FRAMEWORK_TRY_URL`), then prints the link and that channel's free-trial line only.
68
77
  `--channel telegram|whatsapp|imessage` picks the channel (Telegram by default). Run it in the
69
78
  agent's folder: `setup` and `link` refuse a folder with no `package.json` (`not_an_agent_folder`).
70
79
  - A channel that is not open yet is said once ("iMessage isn't open yet; here are your Telegram
71
80
  and WhatsApp links"), with the open channels' links, and exits 0: `link --json` then has
72
81
  `not_open` (the channel asked for), `links` (each open channel's) and `url`/`channel` for the
73
82
  first of them. Only when no channel is open does it exit 3 (`channel_not_open`).
83
+ - Texting needs only Flow Messaging's part of setup (and a model key). When setup's answer has it
84
+ (the join code, the Messaging key and senders) but observe's part failed (traces and the test
85
+ cloud: `observe_failed`, or observe's own limit), `link` prints one warning line on stderr,
86
+ `Traces and tests won't work until .env has FLOW_FRAMEWORK_CLOUD_KEY and FLOW_FRAMEWORK_TRACES_KEY:
87
+ <the server's reason>. Then: <its next>`, then the link, the channel's trial line and its `Next:`
88
+ as usual, and exits 0; `link --json` has the line in `warnings`, on setup's line and on its own.
89
+ Give it to the owner; `setup` makes the missing keys once it is fixed. Run again, `link` finds
90
+ Messaging's part in `.env` and does not run setup. `setup` on its own still exits 3 for it.
74
91
  - On WhatsApp and iMessage the agent can only reply after the owner writes first (WhatsApp: within
75
92
  24 hours of their last message); it cannot message them first. `link --json` says so
76
93
  (`reply_only: true`, and in `say_to_person`): tell the owner.
77
94
  - The starter's `npm start` is on Telegram, plus WhatsApp and iMessage when `.env` has their senders
78
- (`FLOW_MESSAGING_WHATSAPP_SENDER`, `FLOW_MESSAGING_IMESSAGE_SENDER`), and reads `.env` once. When
79
- `setup` or `link` adds a channel (or the model key) it says so: "If npm start is running, restart
80
- it to add iMessage."
95
+ (`FLOW_MESSAGING_WHATSAPP_SENDER`, `FLOW_MESSAGING_IMESSAGE_SENDER`), and reads `.env` once.
96
+ `npm start` marks itself running in `.flow/running.json`, so `link` ends with the step that fits:
97
+ `Next: npm start, then send the link above` when it is not running; when it is, `Next: restart npm
98
+ start (it runs at <url> and lacks iMessage), then send the link above` if `link` just wrote `.env`
99
+ or renamed it, else `Next: send the link above (npm start is running at <url>)` (`link --json`:
100
+ `running`, `false` or `{"url"}`, and `next`). `setup` alone says "If npm start is running, restart it to add
101
+ iMessage."
81
102
  - Give the owner `say_to_person` from `link --json`, and the sign-in link and code from the
82
103
  `pending` line while you wait.
83
104
  - `npm start` connects to Flow Messaging by an outbound event stream. Messages sent while it was
@@ -92,7 +113,7 @@ before you hand it back; run them yourself, and do not offer them to the owner a
92
113
  | 0 | Done | Go on |
93
114
  | 1 | Usage, not signed in, not an agent's folder, or a login URL that is not https | Fix the command, or run `next` |
94
115
  | 2 | flow.engineer could not be reached, or did not answer in time (`timeout`) | Try again |
95
- | 3 | Refused: denied, expired, a product failed, no channel open yet (one closed while others are open is exit 0 with their links), a server with no CLI sign-in (`server_unsupported`), or a free-trial limit (`sandbox_allowance_used`, the iMessage people cap; HTTP 403) | Run `next`; for a trial limit `next` is a sentence: give it to the owner as it is |
116
+ | 3 | Refused: denied, expired, a product failed (for `link`, only when Messaging's part did: observe's alone is exit 0 with a warning), no channel open yet (one closed while others are open is exit 0 with their links), a server with no CLI sign-in (`server_unsupported`), or a free-trial limit (`sandbox_allowance_used`, the iMessage people cap; HTTP 403) | Run `next`; for a trial limit `next` is a sentence: give it to the owner as it is |
96
117
  | 4 | Sign-in still waiting for the owner's approval, or flow.engineer asked to wait (`slow_down`, HTTP 429) | Approval: ask the owner to approve, then run `next` (the same code is kept). `slow_down`: wait `retry_after` seconds (printed as `Wait <n> s`), then run `next` |
97
118
 
98
119
  `login` and `link` wait at most `--timeout` seconds (120 by default) before exit 4, and no single
@@ -108,7 +129,8 @@ Every error is `{"error":{"code","message","next"}}` on stdout with `--json`, an
108
129
  `next` is the exact command or URL to run; when it came from flow.engineer it is the server's.
109
130
  A run gives one `next`: `setup --json`'s is a list in order (a model key first when there is
110
131
  none, then `npm start` for the playground, then `link` for the phone); inside `link` setup's line
111
- has none, and an error's `next` replaces it.
132
+ has none, and `link`'s `next` is one string (a model key first when there is none, then starting or
133
+ restarting `npm start`); an error's `next` replaces it.
112
134
  Run it, or give it to the owner when it needs them (a browser approval, a page to open). A
113
135
  `slow_down` error also has `retry_after` (seconds, or `null` when the server gave none). A
114
136
  trial limit's `next` is a sentence, not a command: it is printed on its own line as it came; pass
@@ -140,6 +162,14 @@ reach (`iMessage: 1 of 2 people`; `trial.imessage.contacts` `{limit, used}` in `
140
162
  with `FLOW_FRAMEWORK_LOGIN_URL` naming another server, commands stop with `signed_in_elsewhere`.
141
163
  - CI never signs in: `npx @flow-engineer/flow setup --ci` says how to give it the test cloud key.
142
164
 
165
+ ## Messages the business starts
166
+
167
+ A nudge, a scheduled message or any turn the person did not start goes with link previews off
168
+ (`preview: false` on the message; Flow Messaging's WhatsApp `preview_url: false`): on WhatsApp a
169
+ preview card's title becomes the phone's notification instead of the message's text. For a call to
170
+ action use `link` (`agent.nudge({ to, text, link: { label, url } })`, or the agent's send with
171
+ `link`), not a URL in the text.
172
+
143
173
  ## Shared test numbers and privacy
144
174
 
145
175
  The free trial runs on Flow's shared Telegram bot, WhatsApp number and iMessage line. Sending an
@@ -1,4 +1,4 @@
1
- // Builds Pip. The playground (main.ts) and the tests build it the same way; tests pass their own
1
+ // Builds the agent. The playground (main.ts) and the tests build it the same way; tests pass their own
2
2
  // model, events and (simulated) tools.
3
3
 
4
4
  import { fileURLToPath } from "node:url";
@@ -11,7 +11,7 @@ export interface BuildOptions {
11
11
  tools?: Tool[];
12
12
  /** Channels that are connected, such as Telegram through Flow Messaging (main.ts adds them when .env has its key); one named whatsapp replaces the unconnected one. */
13
13
  channels?: Channel[];
14
- /** Where Pip runs, for a host with no file system (Cloudflare): its files as text, its database, no schedule timer. */
14
+ /** Where the agent runs, for a host with no file system (Cloudflare): its files as text, its database, no schedule timer. */
15
15
  host?: Pick<AgentOptions, "files" | "database" | "scheduleCheckMs">;
16
16
  }
17
17
 
@@ -20,7 +20,7 @@ export const tools = cafeTools;
20
20
  // The café's numbers and web chat are not connected yet, so every provider refuses to send. Test
21
21
  // people write from the playground, and replies to them go back there, never through these.
22
22
  const notConnected = async () => {
23
- throw new Error("not connected yet: only test people in the playground reach Pip for now");
23
+ throw new Error("not connected yet: only test people in the playground reach the agent for now");
24
24
  };
25
25
  const provider = { sendMessage: notConnected, react: notConnected, typing: notConnected, markRead: notConnected, status: notConnected };
26
26
 
@@ -1,4 +1,4 @@
1
- // Which Flow Messaging channels Pip is on: Telegram always, WhatsApp and iMessage when .env names
1
+ // Which Flow Messaging channels the agent is on: Telegram always, WhatsApp and iMessage when .env names
2
2
  // their sender (FLOW_MESSAGING_WHATSAPP_SENDER, FLOW_MESSAGING_IMESSAGE_SENDER).
3
3
 
4
4
  import type { flowMessaging } from "../../../flow-messaging/src/index.ts";
@@ -1,14 +1,19 @@
1
- // Pip in the playground: `npm start`, then open the printed link and pick a test person. With
2
- // FLOW_MESSAGING_KEY in .env (`npx @flow-engineer/flow link` puts it there), Pip is on Telegram
1
+ // The agent in the playground: `npm start`, then open the printed link and pick a test person. With
2
+ // FLOW_MESSAGING_KEY in .env (`npx @flow-engineer/flow link` puts it there), it is on Telegram
3
3
  // too (and WhatsApp and iMessage when .env names their senders), through Flow Messaging's event
4
- // stream: an outbound connection, so no public URL or tunnel.
4
+ // stream: an outbound connection, so no public URL or tunnel. It listens on PORT, else 8788 or the
5
+ // next free port above it.
5
6
 
7
+ import { readFileSync } from "node:fs";
8
+ import { choosePort, markRunning } from "../../../src/index.ts";
6
9
  import { flowMessaging } from "../../../flow-messaging/src/index.ts";
7
10
  import { simulators } from "../../../simulators/src/index.ts";
8
11
  import { buildAgent } from "./agent.ts";
9
12
  import { channelNames, flowChannels } from "./channels.ts";
10
13
 
11
- const play = simulators({ title: "Pip · Bean & Leaf" });
14
+ // What people call it: package.json's displayName (`npx @flow-engineer/flow link --display-name <name>` changes it).
15
+ const { displayName: name = "your agent" } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { displayName?: string };
16
+ const play = simulators({ title: `${name} · Bean & Leaf` });
12
17
  const fm = process.env.FLOW_MESSAGING_KEY ? flowMessaging() : undefined;
13
18
  const channels = fm ? flowChannels(fm) : [];
14
19
  let agent: ReturnType<typeof buildAgent>;
@@ -18,12 +23,22 @@ try {
18
23
  console.error((error as Error).message); // such as no model key in .env
19
24
  process.exit(1);
20
25
  }
21
- const port = Number(process.env.PORT ?? 8788);
22
- const { url } = await play.listen(agent, port);
23
- console.log(`Pip's playground: ${url}/`);
26
+ let url: string;
27
+ try {
28
+ const { port, note } = await choosePort("PORT", 8788, { command: "npm start" });
29
+ if (note) console.log(note);
30
+ ({ url } = await play.listen(agent, port));
31
+ } catch (error) {
32
+ console.error((error as Error).message); // such as the port PORT names being in use
33
+ process.exit(1);
34
+ }
35
+ const port = Number(new URL(url).port);
36
+ // `npx @flow-engineer/flow link` reads it (.flow/running.json) to say whether to start or restart this.
37
+ markRunning(url);
38
+ console.log(`${name}'s playground: ${url}/`);
24
39
  console.log(`Every channel side by side: ${url}/person`);
25
40
  if (fm) {
26
- // Events sent while Pip was not running come when it starts again (.flow/ keeps the last one handled).
41
+ // Events sent while it was not running come when it starts again (.flow/ keeps the last one handled).
27
42
  const live = fm.start(agent, { cursorFile: ".flow/flow-messaging-cursor", port: port + 1 });
28
43
  console.log(`On ${channelNames(channels)} through Flow Messaging (${live.mode}). The phone link: npx @flow-engineer/flow link`);
29
44
  live.stopped.then((why) => console.error(`Flow Messaging stopped: ${why.message} Then: npx @flow-engineer/flow setup`));
@@ -1,9 +1,11 @@
1
- // Pip's tools and skills as an MCP server, for Claude, ChatGPT or any MCP host: `npm run mcp`.
1
+ // The agent's tools and skills as an MCP server, for Claude, ChatGPT or any MCP host: `npm run mcp`.
2
2
  // The same tools the chat agent uses; no model key needed, since the host brings its own model.
3
3
 
4
+ import { readFileSync } from "node:fs";
4
5
  import { createServer } from "node:http";
5
6
  import { fileURLToPath } from "node:url";
6
7
  import { mcpServer } from "../../../mcp/src/index.ts";
8
+ import { choosePort } from "../../../src/index.ts";
7
9
  import { tools } from "./tools.ts";
8
10
 
9
11
  export const mcp = mcpServer({
@@ -15,8 +17,24 @@ export const mcp = mcpServer({
15
17
  // auth: { issuer, resource, scopes, verify } (the chinwag-auth skill).
16
18
  });
17
19
 
18
- const port = Number(process.env.MCP_PORT ?? 8790);
19
- createServer((req, res) => void mcp.node(req, res)).listen(port, "127.0.0.1", () => {
20
- console.log(`Pip's MCP server: http://127.0.0.1:${port}/mcp`);
21
- console.log(`Try it in Claude Code: claude mcp add --transport http bean-and-leaf http://127.0.0.1:${port}/mcp`);
22
- });
20
+ // What people call it: package.json's displayName.
21
+ const { displayName: name = "your agent" } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { displayName?: string };
22
+ // MCP_PORT, else 8790 or the next free port above it.
23
+ let port: number;
24
+ try {
25
+ const chosen = await choosePort("MCP_PORT", 8790, { command: "npm run mcp" });
26
+ port = chosen.port;
27
+ if (chosen.note) console.log(`${chosen.note} The CLI then needs CAFE_SERVER=http://127.0.0.1:${port}/mcp.`);
28
+ } catch (error) {
29
+ console.error((error as Error).message);
30
+ process.exit(1);
31
+ }
32
+ createServer((req, res) => void mcp.node(req, res))
33
+ .once("error", (error) => {
34
+ console.error(`${error.message}. Run: MCP_PORT=<port> npm run mcp`);
35
+ process.exit(1);
36
+ })
37
+ .listen(port, "127.0.0.1", () => {
38
+ console.log(`${name}'s MCP server: http://127.0.0.1:${port}/mcp`);
39
+ console.log(`Try it in Claude Code: claude mcp add --transport http bean-and-leaf http://127.0.0.1:${port}/mcp`);
40
+ });
@@ -1,4 +1,4 @@
1
- // Pip's tools, over a tiny in-memory café. Placing an order is slow on purpose (a real point-of-sale
1
+ // The agent's tools, over a tiny in-memory café. Placing an order is slow on purpose (a real point-of-sale
2
2
  // takes seconds), so it shows a tool moving to the background.
3
3
 
4
4
  import { tool, z } from "../../../src/index.ts";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flow-engineer/flow",
3
- "version": "0.2.3",
3
+ "version": "0.3.1",
4
4
  "description": "An open-source runtime for chat agents that feel like a person texting back.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {