@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.
Files changed (66) hide show
  1. package/README.md +167 -10
  2. package/coding-agents/auth-skill.md +137 -0
  3. package/coding-agents/context-skill.md +94 -0
  4. package/coding-agents/skill.md +219 -17
  5. package/coding-agents/test-skill.md +3 -1
  6. package/coding-agents/tools-skill.md +78 -1
  7. package/dist/cli/src/auth.d.ts +11 -0
  8. package/dist/cli/src/auth.js +147 -0
  9. package/dist/cli/src/commands.d.ts +119 -0
  10. package/dist/cli/src/commands.js +212 -0
  11. package/dist/cli/src/index.d.ts +2 -0
  12. package/dist/cli/src/index.js +5 -0
  13. package/dist/cli/src/run.d.ts +28 -0
  14. package/dist/cli/src/run.js +523 -0
  15. package/dist/cli/src/terminal.d.ts +3 -0
  16. package/dist/cli/src/terminal.js +135 -0
  17. package/dist/flow-messaging/src/index.js +7 -1
  18. package/dist/mcp/src/client.d.ts +54 -0
  19. package/dist/mcp/src/client.js +175 -0
  20. package/dist/mcp/src/describe.d.ts +41 -0
  21. package/dist/mcp/src/describe.js +110 -0
  22. package/dist/mcp/src/index.d.ts +4 -0
  23. package/dist/mcp/src/index.js +7 -0
  24. package/dist/mcp/src/server.d.ts +70 -0
  25. package/dist/mcp/src/server.js +252 -0
  26. package/dist/mcp/src/terminal.d.ts +24 -0
  27. package/dist/mcp/src/terminal.js +54 -0
  28. package/dist/simulators/src/index.d.ts +1 -1
  29. package/dist/simulators/src/page.html +267 -49
  30. package/dist/simulators/src/server.d.ts +27 -0
  31. package/dist/simulators/src/server.js +264 -40
  32. package/dist/src/agent.d.ts +69 -3
  33. package/dist/src/agent.js +162 -8
  34. package/dist/src/channels.d.ts +2 -0
  35. package/dist/src/conversation.d.ts +16 -1
  36. package/dist/src/conversation.js +140 -12
  37. package/dist/src/events.d.ts +1 -1
  38. package/dist/src/index.d.ts +2 -2
  39. package/dist/src/index.js +1 -1
  40. package/dist/src/model.d.ts +27 -0
  41. package/dist/src/model.js +85 -13
  42. package/dist/src/skills.d.ts +4 -0
  43. package/dist/src/skills.js +23 -0
  44. package/dist/src/store.d.ts +2 -0
  45. package/dist/src/store.js +18 -0
  46. package/dist/src/tools.d.ts +69 -0
  47. package/dist/src/tools.js +17 -1
  48. package/dist/src/traces.js +32 -4
  49. package/dist/src/types.d.ts +2 -0
  50. package/dist/testing/src/account.d.ts +1 -1
  51. package/dist/testing/src/account.js +368 -57
  52. package/dist/testing/src/chat.js +24 -16
  53. package/dist/testing/src/check.d.ts +4 -0
  54. package/dist/testing/src/check.js +90 -0
  55. package/dist/testing/src/cli.js +77 -19
  56. package/dist/testing/src/flow-login.d.ts +33 -0
  57. package/dist/testing/src/flow-login.js +30 -5
  58. package/dist/testing/src/new.d.ts +14 -5
  59. package/dist/testing/src/new.js +54 -20
  60. package/dist/testing/src/plugin.d.ts +4 -0
  61. package/dist/testing/src/plugin.js +116 -0
  62. package/docs/agents.md +87 -12
  63. package/examples/cafe/src/cli.ts +24 -0
  64. package/examples/cafe/src/mcp.ts +22 -0
  65. package/examples/cafe/src/tools.ts +13 -0
  66. package/package.json +28 -1
@@ -28,8 +28,10 @@
28
28
  // free, and POST /api/reset to start a conversation over. Its transcript is kept in memory.
29
29
  //
30
30
  // The person's messages have the agent's ids (m1, m2, ...); the agent's messages get a1, a2, ...
31
- import { createHash } from "node:crypto";
32
- import { readFileSync } from "node:fs";
31
+ import { createHash, randomBytes } from "node:crypto";
32
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
33
+ import { tmpdir } from "node:os";
34
+ import { join } from "node:path";
33
35
  import { createServer } from "node:http";
34
36
  import { declaredTemplate, fillTemplate, fitMessage, } from "../../src/index.js";
35
37
  import { requireToken, sameSecret, wrongTries } from "../../src/http.js";
@@ -41,6 +43,163 @@ export function playgroundClock() {
41
43
  set: (at) => void (offset = Math.max(offset, at - Date.now())),
42
44
  };
43
45
  }
46
+ /** Literal argv, with quoting and escaping, but no shell expansion, pipelines or execution. */
47
+ function cliWords(text) {
48
+ const words = [];
49
+ let word = "", quote = "", started = false, escape = false;
50
+ for (const ch of text) {
51
+ if (escape) {
52
+ word += ch;
53
+ escape = false;
54
+ started = true;
55
+ }
56
+ else if (ch === "\\" && quote !== "'") {
57
+ escape = true;
58
+ started = true;
59
+ }
60
+ else if (quote) {
61
+ if (ch === quote)
62
+ quote = "";
63
+ else
64
+ word += ch;
65
+ }
66
+ else if (ch === '"' || ch === "'") {
67
+ quote = ch;
68
+ started = true;
69
+ }
70
+ else if (/\s/.test(ch)) {
71
+ if (started)
72
+ words.push(word);
73
+ word = "";
74
+ started = false;
75
+ }
76
+ else {
77
+ word += ch;
78
+ started = true;
79
+ }
80
+ }
81
+ if (quote || escape)
82
+ throw new Error("Finish the quoted or escaped argument, then try again.");
83
+ if (started)
84
+ words.push(word);
85
+ return words;
86
+ }
87
+ function localRequest(req) {
88
+ // TODO: hosted CLI execution needs a security review before implementation/deployment.
89
+ // Keep this direct-loopback boundary; hosted support is not enabled by localCli options.
90
+ const ip = req.socket.remoteAddress ?? "";
91
+ return loopback(req.headers.host) && (ip === "::1" || /^127\./.test(ip) || /^::ffff:127\./.test(ip)) && !req.headers["x-forwarded-host"] && !req.headers["x-forwarded-for"];
92
+ }
93
+ /** Only explicitly enabled local playgrounds instantiate a runner; the test cloud never does. */
94
+ function localCliRunner(options, base) {
95
+ const sessions = new Map();
96
+ const dispose = (s) => { s.abort.abort(); s.accept?.(null); rmSync(s.directory, { recursive: true, force: true }); };
97
+ const clean = () => { for (const [key, s] of sessions)
98
+ if (Date.now() - s.touched > 30 * 60_000) {
99
+ dispose(s);
100
+ sessions.delete(key);
101
+ } };
102
+ const timer = setInterval(clean, 60_000);
103
+ timer.unref();
104
+ const close = () => { clearInterval(timer); for (const s of sessions.values())
105
+ dispose(s); sessions.clear(); process.removeListener("exit", close); };
106
+ process.once("exit", close);
107
+ const emit = (s, stream, text) => {
108
+ if (s.abort.signal.aborted)
109
+ return;
110
+ // Keep a reconnectable tail, with a bounded amount of developer-visible output.
111
+ text = text.slice(-128 * 1024);
112
+ s.output.push({ seq: ++s.seq, stream, text });
113
+ s.size += text.length;
114
+ while (s.size > 128 * 1024 && s.output.length > 1)
115
+ s.size -= s.output.shift().text.length;
116
+ };
117
+ const input = (s) => new Promise((resolve) => {
118
+ if (s.abort.signal.aborted)
119
+ return resolve(null);
120
+ s.waiting = true;
121
+ s.accept = (line) => { s.waiting = false; s.accept = undefined; resolve(line); };
122
+ });
123
+ const handle = async (req, res) => {
124
+ if (!localRequest(req) || crossSite(req))
125
+ return forbid(res, "The CLI runner is available only on the local playground's own origin.");
126
+ const url = new URL(req.url ?? "/", "http://localhost");
127
+ const path = inside(url, base);
128
+ clean();
129
+ const cookie = `chinwag_cli_${createHash("sha256").update(`${req.headers.host} ${base}`).digest("hex").slice(0, 12)}`;
130
+ let key = (req.headers.cookie ?? "").split(/;\s*/).find((c) => c.startsWith(`${cookie}=`))?.slice(cookie.length + 1);
131
+ let s = key ? sessions.get(key) : undefined;
132
+ if (req.method === "POST" && path === "/api/cli/reset" && s) {
133
+ dispose(s);
134
+ sessions.delete(key);
135
+ s = undefined;
136
+ }
137
+ if (!s) {
138
+ key = randomBytes(32).toString("base64url");
139
+ const directory = mkdtempSync(join(tmpdir(), "chinwag-cli-"));
140
+ s = { directory, config: { ...options.config, directory }, output: [], seq: 0, size: 0, touched: Date.now(), running: false, waiting: false, abort: new AbortController() };
141
+ const owned = s;
142
+ s.config.openUrl = (link) => emit(owned, "login", link);
143
+ sessions.set(key, s);
144
+ res.appendHeader("set-cookie", `${cookie}=${key}; HttpOnly; SameSite=Lax; Path=${base || "/"}`);
145
+ }
146
+ s.touched = Date.now();
147
+ if (req.method === "GET" && path === "/api/cli") {
148
+ const after = Math.max(0, Number(url.searchParams.get("after")) || 0);
149
+ return json(res, 200, { events: s.output.filter((e) => e.seq > after), seq: s.seq, running: s.running, waiting: s.waiting });
150
+ }
151
+ if (req.method === "POST" && path === "/api/cli/reset")
152
+ return json(res, 200, { ok: true });
153
+ if (req.method === "POST" && path === "/api/cli/input") {
154
+ const body = await readBody(req, MAX_BODY);
155
+ const text = typeof body?.text === "string" ? body.text.slice(0, 4_000) : "";
156
+ if (!text.trim())
157
+ return json(res, 400, { error: "Enter a command or a reply." });
158
+ if (s.running) {
159
+ if (!s.accept)
160
+ return json(res, 409, { error: "The CLI is still running. Wait for its input prompt." });
161
+ emit(s, "input", `${text}\n`);
162
+ s.accept(text);
163
+ return json(res, 202, { ok: true });
164
+ }
165
+ let argv;
166
+ try {
167
+ argv = cliWords(text);
168
+ }
169
+ catch (error) {
170
+ return json(res, 400, { error: error.message });
171
+ }
172
+ if (argv[0] === s.config.name)
173
+ argv.shift();
174
+ if (argv.some((w) => /^--(server|token)(=|$)/.test(w)))
175
+ return json(res, 400, { error: "This runner uses its configured server and its own sign-in. Use auth login." });
176
+ if (argv[0] === "skill" && argv.some((w) => /^--write(=|$)/.test(w)))
177
+ return json(res, 400, { error: "Use the installed CLI to write a skill to your computer; this runner can display it with skill." });
178
+ emit(s, "input", `$ ${text}\n`);
179
+ s.running = true;
180
+ const owned = s;
181
+ const io = {
182
+ env: { NO_COLOR: "1", TERM: "dumb" }, signal: s.abort.signal, interactive: true,
183
+ write: (text) => emit(owned, "stdout", text), writeError: (text) => emit(owned, "stderr", text),
184
+ async *lines(prompt) {
185
+ while (!owned.abort.signal.aborted) {
186
+ if (prompt)
187
+ emit(owned, "stdout", prompt);
188
+ const line = await input(owned);
189
+ if (line === null)
190
+ return;
191
+ yield line;
192
+ }
193
+ },
194
+ confirm: async (question) => { emit(owned, "stderr", `${question} [y/N] `); return /^y(es)?$/i.test((await input(owned)) ?? ""); },
195
+ };
196
+ void import("../../cli/src/run.js").then(({ runCli }) => runCli(owned.config, argv, io)).then((code) => emit(owned, "exit", String(code)), () => { emit(owned, "stderr", "The CLI could not finish this command.\n"); emit(owned, "exit", "1"); }).finally(() => { owned.running = false; owned.waiting = false; owned.accept = undefined; });
197
+ return json(res, 202, { ok: true });
198
+ }
199
+ res.writeHead(404).end();
200
+ };
201
+ return Object.assign(handle, { close });
202
+ }
44
203
  // What a US SMS provider sends for the standard keywords (Twilio's defaults).
45
204
  const CARRIER = {
46
205
  stop: ["STOP", "STOPALL", "UNSUBSCRIBE", "CANCEL", "END", "QUIT"],
@@ -186,6 +345,7 @@ export function simulators(options = {}) {
186
345
  ...(outbound.buttons ? { buttons: outbound.buttons } : {}),
187
346
  ...(outbound.list ? { list: outbound.list } : {}),
188
347
  ...(outbound.link ? { link: outbound.link } : {}),
348
+ ...(outbound.authentication ? { authentication: true } : {}),
189
349
  ...(outbound.image ? { image: outbound.image } : {}),
190
350
  ...(outbound.fields ? { fields: outbound.fields } : {}),
191
351
  ...(sms ? { sms: { segments: sms.segments, encoding: sms.encoding } } : {}),
@@ -382,38 +542,46 @@ export function simulators(options = {}) {
382
542
  };
383
543
  return testCloud;
384
544
  }
385
- // ---------------------------------------------------------------------------------------------
386
- // The playground.
387
- /** The test person (by phone) each conversation belongs to, so its trace shows in their simulators. */
388
545
  const people = new Map();
389
546
  let playing = null;
390
- const threadKey = (channel, person) => `${channel}:${person.phone}`;
547
+ const personKey = (person) => person.key ? `account:${person.key}` : person.visitor ? `visitor:${person.visitor}` : person.phone;
548
+ const threadKey = (channel, person) => `${channel}:${personKey(person)}`;
549
+ const rememberPerson = (id, person) => {
550
+ const all = people.get(id) ?? new Map();
551
+ all.set(personKey(person), person);
552
+ people.set(id, all);
553
+ };
554
+ const conversation = (agent, person) => person.key
555
+ ? agent.visitorConversation(person.key) : person.visitor ? agent.visitorConversation(null, { channel: person.visitorChannel, address: person.visitor }) : agent.testConversation(person.phone);
391
556
  const playgroundEvents = (event) => {
392
- const person = people.get(event.conversationId);
557
+ const known = people.get(event.conversationId);
558
+ const person = known?.values().next().value;
393
559
  // A failed turn (a wrong or expired model key, say) on the terminal too, also for no known person.
394
560
  if (event.type === "model.error")
395
561
  console.error(`flow: model error${person ? ` (${person.name})` : ""}: ${String(event.error)}`);
396
- if (!person || !playing)
562
+ if (!known || !playing)
397
563
  return;
398
- for (const c of playing.channels())
399
- show(threadKey(c.name, person), event);
564
+ for (const person of known.values())
565
+ for (const c of playing.channels())
566
+ show(threadKey(c.name, person), event);
400
567
  };
401
568
  function attach(agent) {
402
569
  playing = agent;
403
570
  agent.attachPlayground(({ conversationId, person, channel, outbound }) => {
404
- people.set(conversationId, person);
571
+ rememberPerson(conversationId, { ...person, visitorChannel: channel });
405
572
  const own = agent.channels().find((c) => c.name === channel);
406
573
  if (own)
407
- return deliverer(platformFor(own), threadKey(channel, person), agent.templates?.())(outbound);
574
+ for (const other of people.get(conversationId).values())
575
+ deliverer(platformFor(own), threadKey(channel, other), agent.templates?.())(outbound);
408
576
  });
409
577
  }
410
578
  /** The conversation on one channel, rebuilt from the agent's store, with what only the platform showed (the carrier's replies). */
411
579
  async function load(agent, person, channel) {
412
580
  const k = threadKey(channel.name, person);
413
581
  const t = thread(k);
414
- const id = await agent.testConversation(person.phone);
582
+ const id = await conversation(agent, person);
415
583
  if (id)
416
- people.set(id, person);
584
+ rememberPerson(id, person);
417
585
  const p = platformFor(channel);
418
586
  let count = 0;
419
587
  const lines = [];
@@ -446,7 +614,7 @@ export function simulators(options = {}) {
446
614
  t.agentCount = count;
447
615
  return t;
448
616
  }
449
- async function handle(agent, req, res, base, gate) {
617
+ async function handle(agent, req, res, base, gate, cli) {
450
618
  const url = new URL(req.url ?? "/", "http://localhost");
451
619
  const path = inside(url, base);
452
620
  // Without a token, a page on another site (or a DNS name rebound to 127.0.0.1) must not run
@@ -457,6 +625,13 @@ export function simulators(options = {}) {
457
625
  return forbid(res, "This playground's API answers only its own page.");
458
626
  if (gate && !gate.allows(req, res, url, base))
459
627
  return;
628
+ if (path === "/api/cli" || path.startsWith("/api/cli/")) {
629
+ if (!cli) {
630
+ res.writeHead(404).end();
631
+ return;
632
+ }
633
+ return cli.handle(req, res);
634
+ }
460
635
  if (req.method === "GET" && path === "/")
461
636
  return html(res, page(playPage, base));
462
637
  if (req.method === "GET" && path === "/person")
@@ -466,7 +641,8 @@ export function simulators(options = {}) {
466
641
  if (req.method === "GET" && path === "/api/platforms") {
467
642
  const channels = agent.channels().map((c) => ({ name: c.name, kind: c.kind, platform: platformFor(c) }));
468
643
  return json(res, 200, {
469
- platforms: PLATFORMS, channels, people: agent.testPeople(), cards: options.cards ?? [],
644
+ platforms: PLATFORMS, channels, people: agent.testPeople(), cards: options.cards ?? [], authentication: !!options.authentication,
645
+ ...(cli && localRequest(req) ? { localCli: { channel: cli.options.channel, name: cli.options.config.name } } : {}),
470
646
  templates: (agent.templates?.() ?? []).map((t) => ({ name: t.name, body: t.body })),
471
647
  panel: { person: !!(agent.contact && agent.outbox), nudge: !!agent.recordNudge, clock: !!(options.clock && agent.runDue) },
472
648
  });
@@ -491,11 +667,42 @@ export function simulators(options = {}) {
491
667
  return json(res, 200, { now: options.clock.now(), ran });
492
668
  }
493
669
  const asked = (name) => (typeof body?.[name] === "string" ? body[name] : (url.searchParams.get(name) ?? ""));
494
- const person = agent.testPeople().find((p) => digits(p.phone) === digits(asked("person")) && digits(p.phone) !== "");
670
+ const isVisitor = asked("person") === "visitor";
671
+ let person = agent.testPeople().find((p) => digits(p.phone) === digits(asked("person")) && digits(p.phone) !== "");
672
+ const channel = agent.channels().find((c) => c.name === asked("channel"));
673
+ let visitorAddress = "";
674
+ if (isVisitor && options.authentication) {
675
+ if (!channel)
676
+ return json(res, 400, { error: "channel must be one of the agent's own channels" });
677
+ if (!agent.simulateVisitor || !agent.visitorConversation)
678
+ return json(res, 400, { error: "this playground needs an agent with authenticated visitor support" });
679
+ const cookieName = `chinwag_visitor_${createHash("sha256").update(`${req.headers.host} ${base}`).digest("hex").slice(0, 12)}`;
680
+ let browser = (req.headers.cookie ?? "").split(/;\s*/).find((cookie) => cookie.startsWith(`${cookieName}=`))?.slice(cookieName.length + 1);
681
+ if (!browser || !/^[A-Za-z0-9_-]{43}$/.test(browser))
682
+ browser = undefined;
683
+ const reset = req.method === "POST" && path === "/api/auth/reset";
684
+ if (!browser || reset) {
685
+ browser = randomBytes(32).toString("base64url");
686
+ const secure = req.socket.encrypted || req.headers["x-forwarded-proto"] === "https";
687
+ res.setHeader("set-cookie", `${cookieName}=${browser}; HttpOnly; SameSite=Lax; Path=${base || "/"}; Max-Age=2592000${secure ? "; Secure" : ""}`);
688
+ }
689
+ // Each interface needs its own proof. A web login cannot authenticate an unrelated CLI.
690
+ const visitor = createHash("sha256").update(JSON.stringify([browser, channel.name])).digest("base64url");
691
+ const state = await options.authentication({ channel, visitor });
692
+ const publicState = { signedIn: !!state.key, label: state.label, signInUrl: state.signInUrl };
693
+ if (path === "/api/auth" || reset)
694
+ return json(res, 200, publicState);
695
+ visitorAddress = state.address ?? visitor;
696
+ person = state.key ? { phone: "", name: "Signed-in user", key: state.key } : { phone: "", name: "New user", visitor: visitorAddress, visitorChannel: channel.name };
697
+ }
698
+ else if (!person)
699
+ return json(res, 403, { error: "choose a declared test person, or a new user when sign-in is configured" });
495
700
  if (!person)
496
- return json(res, 403, { error: "only this agent's test people can be played: declare them in createAgent({ testPeople })" });
701
+ return json(res, 403, { error: "Sign in to continue." });
702
+ if (path === "/api/auth")
703
+ return json(res, 200, { signedIn: true });
497
704
  if (req.method === "POST" && path === "/api/clear") {
498
- const id = await agent.testConversation(person.phone);
705
+ const id = await conversation(agent, person);
499
706
  if (id) {
500
707
  await agent.forgetPerson(id);
501
708
  people.delete(id);
@@ -507,21 +714,21 @@ export function simulators(options = {}) {
507
714
  }
508
715
  return json(res, 200, { ok: true });
509
716
  }
510
- const channel = agent.channels().find((c) => c.name === asked("channel"));
511
717
  if (!channel)
512
718
  return json(res, 400, { error: "channel must be one of the agent's own channels" });
513
719
  const k = threadKey(channel.name, person);
514
720
  const p = platformFor(channel);
515
721
  // The Person panel: what is set and waiting for this person, and the business's side (nudges).
516
- const to = { channel: channel.name, address: channel.kind === "custom" ? `test:${person.phone}` : person.phone };
722
+ const to = { channel: channel.name, address: person.key ? visitorAddress : person.visitor ? `visitor:${visitorAddress}` : channel.kind === "custom" ? `test:${person.phone}` : person.phone };
517
723
  if (req.method === "GET" && path === "/api/person") {
518
724
  if (!agent.contact || !agent.outbox)
519
725
  return json(res, 404, { error: "this agent has no contact record" });
520
- const id = await agent.testConversation(person.phone);
726
+ const id = await conversation(agent, person);
521
727
  // One person on every channel: their record from whichever channel they have written on.
522
728
  let contact = await agent.contact(to);
523
- for (const c of agent.channels())
524
- contact ??= await agent.contact({ channel: c.name, address: c.kind === "custom" ? `test:${person.phone}` : person.phone });
729
+ if (!person.key && !person.visitor)
730
+ for (const c of agent.channels())
731
+ contact ??= await agent.contact({ channel: c.name, address: c.kind === "custom" ? `test:${person.phone}` : person.phone });
525
732
  // When the agent wrote on each channel (its latest 20), so the page can flag a channel it is not showing.
526
733
  const lastOut = {};
527
734
  for (const e of id ? await agent.transcript(id) : [])
@@ -570,7 +777,7 @@ export function simulators(options = {}) {
570
777
  const t = await load(agent, person, channel);
571
778
  // Taken with the lines, in the same step: every event after this number came after these lines.
572
779
  recentOf(k);
573
- return json(res, 200, { channel: channel.name, person, optedOut: t.optedOut, lines: t.lines, seq });
780
+ return json(res, 200, { channel: channel.name, person: { name: person.name, phone: person.phone }, optedOut: t.optedOut, lines: t.lines, seq });
574
781
  }
575
782
  if (req.method === "GET" && path === "/api/events")
576
783
  return events(req, res, k, url);
@@ -580,7 +787,7 @@ export function simulators(options = {}) {
580
787
  const messageId = typeof body?.messageId === "string" ? body.messageId : "";
581
788
  const emoji = typeof body?.emoji === "string" ? body.emoji.slice(0, 16) : "";
582
789
  const target = findLine(k, messageId);
583
- const id = await agent.testConversation(person.phone);
790
+ const id = await conversation(agent, person);
584
791
  if (!p.personReactions || !target || target.kind !== "out" || !emoji || !id) {
585
792
  return json(res, 400, { error: "reactions need a platform that has them, an agent message id and an emoji" });
586
793
  }
@@ -593,12 +800,13 @@ export function simulators(options = {}) {
593
800
  if (!text && !attachmentsOf(body).length)
594
801
  return json(res, 400, { error: "text or a file is required" });
595
802
  const { incoming, line } = incomingOf(k, body, text, Boolean(p.replies));
596
- console.info(JSON.stringify({ chinwag: "playground.message", person: person.name, phone: person.phone, channel: channel.name, at: new Date(line.at).toISOString(), ip: req.socket.remoteAddress ?? null }));
803
+ console.info(JSON.stringify({ chinwag: "playground.message", person: person.name, channel: channel.name, at: new Date(line.at).toISOString() }));
597
804
  emitLine(k, line);
598
805
  carrier(p, k, text);
599
- const taken = await agent.simulate({ person: person.phone, channel: channel.name, message: incoming });
806
+ const taken = person.key || person.visitor ? await agent.simulateVisitor({ key: person.key, address: visitorAddress, channel: channel.name, message: incoming })
807
+ : await agent.simulate({ person: person.phone, channel: channel.name, message: incoming });
600
808
  if (taken)
601
- people.set(taken.conversationId, person);
809
+ rememberPerson(taken.conversationId, person);
602
810
  line.id = taken?.messageId ?? null;
603
811
  push(k, { type: "id", at: line.at, messageId: line.id });
604
812
  return json(res, 202, { messageId: line.id, at: line.at });
@@ -607,29 +815,41 @@ export function simulators(options = {}) {
607
815
  }
608
816
  const playground = {
609
817
  events: playgroundEvents,
610
- listen: (agent, port, listenOptions = {}) => {
818
+ listen: async (agent, port, listenOptions = {}) => {
611
819
  const gate = listenOptions.token === undefined ? null : tokenGate(listenOptions.token);
612
820
  attach(agent);
613
- return serve(guarded((req, res) => handle(agent, req, res, "", gate)), port);
821
+ const runner = listenOptions.localCli ? localCliRunner(listenOptions.localCli, "") : undefined;
822
+ const cli = runner ? { options: listenOptions.localCli, handle: runner } : undefined;
823
+ try {
824
+ const listening = await serve(guarded((req, res) => handle(agent, req, res, "", gate, cli)), port);
825
+ return { ...listening, close: async () => { runner?.close(); await listening.close(); } };
826
+ }
827
+ catch (error) {
828
+ runner?.close();
829
+ throw error;
830
+ }
614
831
  },
615
832
  handler: (agent, handlerOptions) => {
616
833
  const gate = tokenGate(handlerOptions?.token);
617
834
  attach(agent);
618
835
  const base = handlerOptions.base ?? "";
619
- return guarded((req, res) => handle(agent, req, res, base, gate));
836
+ const cli = handlerOptions.localCli ? { options: handlerOptions.localCli, handle: localCliRunner(handlerOptions.localCli, base) } : undefined;
837
+ return guarded((req, res) => handle(agent, req, res, base, gate, cli));
620
838
  },
621
839
  };
622
840
  return playground;
623
841
  }
624
842
  /**
625
843
  * The playground's lock on a deployed agent. The token comes once as `?token=` (swapped for an
626
- * HttpOnly, SameSite=Strict cookie, and dropped from the URL) or on every API call as
627
- * `Authorization: Bearer`. Compared in constant time; wrong tokens are limited per address.
844
+ * HttpOnly, SameSite=Lax cookie, and dropped from the URL) or on every API call as
845
+ * `Authorization: Bearer`. Lax lets sign-in return from another site. Compared in constant time; wrong tokens are limited per address.
628
846
  */
629
847
  function tokenGate(token) {
630
848
  requireToken(token, "flow simulators: a deployed playground");
631
849
  // The cookie holds a value made from the token, not the token itself.
632
850
  const session = createHash("sha256").update(`chinwag-playground:${token}`).digest("hex");
851
+ // Cookies are shared across localhost ports: independent agents must not overwrite each other.
852
+ const cookieName = `${COOKIE}_${session.slice(0, 12)}`;
633
853
  const tries = wrongTries();
634
854
  return {
635
855
  allows(req, res, url, base) {
@@ -640,6 +860,8 @@ function tokenGate(token) {
640
860
  refuse(res, 401);
641
861
  return false;
642
862
  };
863
+ const secure = req.socket.encrypted || req.headers["x-forwarded-proto"] === "https";
864
+ const sessionCookie = `${cookieName}=${session}; Path=${base || "/"}; HttpOnly; SameSite=Lax${secure ? "; Secure" : ""}`;
643
865
  const query = url.searchParams.get("token");
644
866
  const bearer = /^Bearer (.+)$/.exec(req.headers.authorization ?? "")?.[1];
645
867
  if ((query !== null || bearer !== undefined) && blocked) {
@@ -650,17 +872,19 @@ function tokenGate(token) {
650
872
  if (req.method !== "GET" || !sameSecret(query, token))
651
873
  return fail();
652
874
  url.searchParams.delete("token");
653
- const secure = req.socket.encrypted || req.headers["x-forwarded-proto"] === "https";
654
- const cookie = `${COOKIE}=${session}; Path=${base || "/"}; HttpOnly; SameSite=Strict${secure ? "; Secure" : ""}`;
655
875
  const rest = url.searchParams.toString();
656
- res.writeHead(303, { "set-cookie": cookie, location: url.pathname + (rest ? `?${rest}` : ""), "cache-control": "no-store" }).end();
876
+ res.writeHead(303, { "set-cookie": sessionCookie, location: url.pathname + (rest ? `?${rest}` : ""), "cache-control": "no-store" }).end();
657
877
  return false;
658
878
  }
659
879
  if (bearer !== undefined)
660
880
  return sameSecret(bearer, token) ? true : fail();
661
- const cookie = (req.headers.cookie ?? "").split(/;\s*/).find((c) => c.startsWith(`${COOKIE}=`))?.slice(COOKIE.length + 1);
662
- if (cookie !== undefined && sameSecret(cookie, session))
881
+ const cookie = (req.headers.cookie ?? "").split(/;\s*/).find((c) => c.startsWith(`${cookieName}=`))?.slice(cookieName.length + 1);
882
+ if (cookie !== undefined && sameSecret(cookie, session)) {
883
+ // Renew on page visits so existing Strict cookies also survive the next sign-in round trip.
884
+ if (req.method === "GET" && ["/", "/person"].includes(inside(url, base)))
885
+ res.setHeader("set-cookie", sessionCookie);
663
886
  return true;
887
+ }
664
888
  refuse(res, 401);
665
889
  return false;
666
890
  },
@@ -749,7 +973,7 @@ async function readBody(req, limit = MAX_BODY) {
749
973
  }
750
974
  }
751
975
  function json(res, status, value) {
752
- res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(value));
976
+ res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" }).end(JSON.stringify(value));
753
977
  }
754
978
  function html(res, body) {
755
979
  res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }).end(body);
@@ -4,7 +4,7 @@ import { type Database } from "./db.ts";
4
4
  import { type EventSink } from "./events.ts";
5
5
  import { type AgentFiles } from "./skills.ts";
6
6
  import { type ConversationRow, type QuietHours, type StoreApi, type TimeZoneSource } from "./store.ts";
7
- import { type Tool } from "./tools.ts";
7
+ import { type ChatAuth, type CredentialProvider, type Tool } from "./tools.ts";
8
8
  import { type TracesOptions } from "./traces.ts";
9
9
  import type { ChannelInfo, Deliver, Incoming, Model, Outbound, Template, TemplateDefinition, Transcribe } from "./types.ts";
10
10
  export interface AgentOptions {
@@ -19,6 +19,10 @@ export interface AgentOptions {
19
19
  */
20
20
  transcribe?: Transcribe;
21
21
  tools?: Tool[];
22
+ /** Backend credentials or clients for a verified person, available only to tool code as context.credentials. */
23
+ credentials?: CredentialProvider;
24
+ /** Enforce per-tool signIn in chat and send the brand's login challenge when needed. */
25
+ auth?: ChatAuth;
22
26
  /**
23
27
  * Added to the system prompt at the start of every turn, before the model is called: keep it to
24
28
  * small facts at hand. It runs on every turn, so a lookup here (an API call, a slow query) delays
@@ -91,11 +95,13 @@ export interface AgentOptions {
91
95
  channels?: Channel[];
92
96
  /**
93
97
  * The owner's own key for a person (a member id), from a channel identity, or null when there is
94
- * none. Identities with the same key are one person.
98
+ * none. Identities with the same key are one person. `phone` is normalized and present only
99
+ * when this channel verifies phone identities; an arbitrary address is never phone evidence.
95
100
  */
96
101
  resolve?: (identity: {
97
102
  channel: string;
98
103
  address: string;
104
+ phone?: string;
99
105
  }) => Promise<string | null> | string | null;
100
106
  /**
101
107
  * The calling code ("+91", "+44", "+1") for phone numbers that come without one. Set it when
@@ -161,7 +167,10 @@ export interface TestPerson {
161
167
  /** Where the playground shows what the agent sends to a test person who wrote from it. */
162
168
  export type PlaygroundSink = (delivery: {
163
169
  conversationId: string;
164
- person: TestPerson;
170
+ person: TestPerson & {
171
+ key?: string;
172
+ visitor?: string;
173
+ };
165
174
  channel: string;
166
175
  outbound: Outbound;
167
176
  }) => Promise<void> | void;
@@ -272,6 +281,8 @@ export declare class Agent {
272
281
  /** Conversations in the store, known once resume() ran. */
273
282
  private readonly rows;
274
283
  private resumed;
284
+ private readonly requestReplies;
285
+ private readonly requestCredentials;
275
286
  private readonly arriving;
276
287
  /** Callers waiting for a queued nudge to settle: its first call, and any with the same key. */
277
288
  private readonly nudgeWaiters;
@@ -280,6 +291,7 @@ export declare class Agent {
280
291
  /** Where each identity (channel, address) last wrote from; sends go back the same way. */
281
292
  private readonly via;
282
293
  private playgroundSink;
294
+ private readonly playgroundVisitors;
283
295
  /** Before this, this runner does not ask for the pruning claim again (it, or another runner, had it). */
284
296
  private pruneAfter;
285
297
  /** `defaultCountryCode`'s digits. */
@@ -306,6 +318,20 @@ export declare class Agent {
306
318
  conversationId: string;
307
319
  messageId: string;
308
320
  } | null>;
321
+ /**
322
+ * A signed-in HTTP channel's request. `key` must come from verified auth, never the body.
323
+ * Uses the same person as resolve() on other channels. The caller serializes requests per key.
324
+ */
325
+ request(input: {
326
+ channel: string;
327
+ key: string;
328
+ text: string;
329
+ conversationId?: string;
330
+ credentials?: CredentialProvider;
331
+ }): Promise<{
332
+ conversationId: string;
333
+ replies: Outbound[];
334
+ }>;
309
335
  /**
310
336
  * A message from a test person (by their phone number) in the test playground: exactly what
311
337
  * message() does for the same person writing on that channel, but its replies go to the
@@ -320,6 +346,46 @@ export declare class Agent {
320
346
  conversationId: string;
321
347
  messageId: string;
322
348
  } | null>;
349
+ /**
350
+ * A playground visitor, signed out or authenticated. `key` is verified by server code, never
351
+ * accepted from the browser. Replies stay in the simulator; no provider send is made.
352
+ */
353
+ simulateVisitor(input: {
354
+ key?: string | null;
355
+ address: string;
356
+ channel: string;
357
+ message: string | Incoming;
358
+ }): Promise<{
359
+ conversationId: string;
360
+ messageId: string;
361
+ } | null>;
362
+ /**
363
+ * A completed server-verified login. Bind its channel address to the account before sending a
364
+ * confirmation with recordNudge/nudge. Never take key from a browser body or model input.
365
+ * Anonymous history stays separate; an identity belonging to another account is refused.
366
+ */
367
+ linkIdentity(input: {
368
+ channel: string;
369
+ address: string;
370
+ key: string;
371
+ via?: "provider" | "playground";
372
+ }): Promise<{
373
+ conversationId: string;
374
+ }>;
375
+ /** Wake the agent after verified sign-in; a repeated completion queues one internal event. */
376
+ signInCompleted(input: {
377
+ channel: string;
378
+ address: string;
379
+ key: string;
380
+ idempotencyKey: string;
381
+ conversationId?: string;
382
+ via?: "provider" | "playground";
383
+ }): Promise<NudgeState>;
384
+ /** A verified playground visitor's account history, including messages on other interfaces. */
385
+ visitorConversation(key: string | null, identity?: {
386
+ channel: string;
387
+ address: string;
388
+ }): Promise<string | null>;
323
389
  /** They wrote, so they are there: what was held for their quiet hours goes now, in this turn. */
324
390
  private released;
325
391
  /**