@flow-engineer/flow 0.2.1 → 0.2.3

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 (70) 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/client.d.ts +2 -2
  18. package/dist/flow-messaging/src/client.js +3 -3
  19. package/dist/flow-messaging/src/index.js +17 -9
  20. package/dist/mcp/src/client.d.ts +54 -0
  21. package/dist/mcp/src/client.js +175 -0
  22. package/dist/mcp/src/describe.d.ts +41 -0
  23. package/dist/mcp/src/describe.js +110 -0
  24. package/dist/mcp/src/index.d.ts +4 -0
  25. package/dist/mcp/src/index.js +7 -0
  26. package/dist/mcp/src/server.d.ts +70 -0
  27. package/dist/mcp/src/server.js +252 -0
  28. package/dist/mcp/src/terminal.d.ts +24 -0
  29. package/dist/mcp/src/terminal.js +54 -0
  30. package/dist/simulators/src/index.d.ts +1 -1
  31. package/dist/simulators/src/page.html +267 -49
  32. package/dist/simulators/src/server.d.ts +27 -0
  33. package/dist/simulators/src/server.js +264 -40
  34. package/dist/src/agent.d.ts +69 -3
  35. package/dist/src/agent.js +162 -8
  36. package/dist/src/channels.d.ts +6 -4
  37. package/dist/src/channels.js +4 -3
  38. package/dist/src/conversation.d.ts +17 -1
  39. package/dist/src/conversation.js +170 -16
  40. package/dist/src/events.d.ts +1 -1
  41. package/dist/src/index.d.ts +2 -2
  42. package/dist/src/index.js +1 -1
  43. package/dist/src/model.d.ts +27 -0
  44. package/dist/src/model.js +85 -13
  45. package/dist/src/skills.d.ts +4 -0
  46. package/dist/src/skills.js +23 -0
  47. package/dist/src/store.d.ts +2 -0
  48. package/dist/src/store.js +18 -0
  49. package/dist/src/tools.d.ts +69 -0
  50. package/dist/src/tools.js +17 -1
  51. package/dist/src/traces.js +32 -4
  52. package/dist/src/types.d.ts +5 -0
  53. package/dist/testing/src/account.d.ts +1 -1
  54. package/dist/testing/src/account.js +368 -57
  55. package/dist/testing/src/chat.js +24 -16
  56. package/dist/testing/src/check.d.ts +4 -0
  57. package/dist/testing/src/check.js +90 -0
  58. package/dist/testing/src/cli.js +77 -19
  59. package/dist/testing/src/flow-login.d.ts +33 -0
  60. package/dist/testing/src/flow-login.js +30 -5
  61. package/dist/testing/src/new.d.ts +14 -5
  62. package/dist/testing/src/new.js +54 -20
  63. package/dist/testing/src/plugin.d.ts +4 -0
  64. package/dist/testing/src/plugin.js +116 -0
  65. package/docs/agents.md +87 -12
  66. package/examples/cafe/src/cli.ts +24 -0
  67. package/examples/cafe/src/mcp.ts +22 -0
  68. package/examples/cafe/src/tools.ts +13 -0
  69. package/flow-messaging/README.md +3 -1
  70. package/package.json +28 -1
@@ -13,26 +13,50 @@
13
13
  :root { --bg: #161615; --panel: #1f1f1d; --ink: #ecebe6; --quiet: #9a9a94; --line: #33332f; }
14
14
  }
15
15
  * { box-sizing: border-box; }
16
- body { margin: 0; font: 14px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: var(--bg); color: var(--ink); }
16
+ [hidden] { display: none !important; }
17
+ body { margin: 0; font: 14px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: var(--bg); color: var(--ink); height: 100dvh; display: grid; grid-template-rows: auto minmax(0, 1fr); }
17
18
  button { font: inherit; }
18
- .top { display: flex; align-items: center; gap: 16px; padding: 12px 16px; border-bottom: 1px solid var(--line); background: var(--panel); flex-wrap: wrap; }
19
- .top h1 { font-size: 15px; margin: 0; font-weight: 600; }
20
- .top h1 small { color: var(--quiet); font-weight: 400; }
21
- .tabs { display: flex; gap: 4px; flex-wrap: wrap; }
22
- .tab { display: flex; align-items: center; gap: 6px; padding: 6px 10px; border: 1px solid var(--line); border-radius: 999px; background: transparent; color: var(--ink); cursor: pointer; }
23
- .tab img { width: 16px; height: 16px; }
24
- .tab small { color: var(--quiet); font-size: 11px; }
25
- .tab[aria-pressed="true"] { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
26
- .tab { position: relative; }
27
- .tab .unread { position: absolute; top: -5px; right: -5px; min-width: 18px; height: 18px; padding: 0 5px; border-radius: 999px; background: #e5484d; color: #fff; font-size: 11px; font-weight: 700; line-height: 18px; text-align: center; }
28
- .spacer { flex: 1; }
29
- .ghost { padding: 6px 10px; border: 1px solid var(--line); border-radius: 8px; background: transparent; color: var(--ink); cursor: pointer; }
30
- select.ghost { max-width: 240px; }
19
+ .top { display: grid; grid-template-columns: minmax(160px, 1fr) auto minmax(0, 1fr); align-items: center; gap: 20px; padding: 12px 20px; border-bottom: 1px solid var(--line); background: var(--panel); }
20
+ .top h1 { min-width: 0; font-size: 14px; margin: 0; font-weight: 650; line-height: 1.35; }
21
+ .top h1 .agent-title { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
22
+ .top h1 small { display: block; margin-top: 2px; color: var(--quiet); font-size: 11px; font-weight: 400; }
23
+ .tabs { display: flex; align-items: center; justify-self: center; gap: 4px; max-width: 100%; padding: 6px; border: 1px solid var(--line); border-radius: 14px; background: var(--bg); overflow-x: auto; scrollbar-width: thin; }
24
+ .tab { position: relative; display: flex; flex: none; align-items: center; justify-content: center; min-width: 36px; height: 36px; padding: 8px; border: 0; border-radius: 9px; background: transparent; color: var(--quiet); cursor: pointer; transition: background .16s, color .16s; }
25
+ .tab img, .tab svg { display: block; width: 20px; height: 20px; flex: none; }
26
+ .tab-icon.web { color: #4679d8; } .tab-icon.terminal { color: var(--ink); } .tab-icon.imessage { color: #32bc4c; } .tab-icon.sms { color: #3578e5; }
27
+ .tab-label { display: flex; align-items: center; gap: 6px; max-width: 0; opacity: 0; overflow: hidden; white-space: nowrap; padding-left: 0; font-size: 12px; font-weight: 550; transition: max-width .2s ease, opacity .16s ease, padding-left .2s ease; }
28
+ .tab:hover .tab-label, .tab:focus-visible .tab-label { max-width: 160px; opacity: 1; padding-left: 8px; }
29
+ .tab:hover { background: var(--panel); color: var(--ink); }
30
+ .tab[aria-pressed="true"] { background: var(--panel); color: var(--accent); box-shadow: 0 0 0 1px var(--accent) inset, 0 1px 3px rgba(0,0,0,.04); }
31
+ .tab:focus-visible, .ghost:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
32
+ .tab .unread { position: absolute; top: -3px; right: -3px; min-width: 15px; height: 15px; padding: 0 4px; border-radius: 999px; background: #e5484d; color: #fff; font-size: 9px; font-weight: 700; line-height: 15px; text-align: center; }
33
+ .header-controls { display: flex; justify-self: end; align-items: flex-end; gap: 8px; white-space: nowrap; }
34
+ .person-picker { display: grid; gap: 3px; min-width: 0; }
35
+ .person-picker > span { color: var(--quiet); font-size: 10px; font-weight: 550; }
36
+ .ghost { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 34px; padding: 6px 10px; border: 1px solid var(--line); border-radius: 8px; background: transparent; color: var(--ink); cursor: pointer; }
37
+ .ghost:hover { background: var(--bg); }
38
+ .header-actions { display: flex; align-items: center; gap: 6px; padding-left: 10px; border-left: 1px solid var(--line); }
39
+ .header-actions .ghost { font-size: 12px; }
40
+ .header-actions svg { width: 14px; height: 14px; flex: none; }
41
+ select.ghost { display: block; width: 190px; max-width: 100%; font-size: 12px; text-overflow: ellipsis; }
42
+ @media (max-width: 1240px) {
43
+ .top { grid-template-columns: minmax(0, 1fr) auto; gap: 12px 20px; }
44
+ .tabs { grid-row: 2; grid-column: 1 / -1; justify-self: start; }
45
+ }
46
+ @media (max-width: 580px) {
47
+ .top { grid-template-columns: minmax(0, 1fr); padding: 12px 14px; gap: 12px; }
48
+ .tabs { grid-row: auto; grid-column: auto; justify-self: stretch; width: fit-content; }
49
+ .header-controls { justify-self: stretch; justify-content: space-between; gap: 10px; }
50
+ .person-picker { flex: 1; } select.ghost { width: 100%; }
51
+ .header-actions { padding-left: 8px; }
52
+ }
53
+ @media (hover: none) { .tab:hover:not(:focus-visible) .tab-label { max-width: 0; opacity: 0; padding-left: 0; } }
54
+ @media (prefers-reduced-motion: reduce) { .tab, .tab-label { transition: none; } }
31
55
  .empty { grid-column: 1 / -1; padding: 32px; text-align: center; color: var(--quiet); }
32
56
  .empty code { background: var(--panel); border: 1px solid var(--line); border-radius: 6px; padding: 1px 6px; }
33
57
  body:not(.play) .play-only, body.play .cloud-only { display: none; }
34
- main { display: grid; grid-template-columns: minmax(0, 400px) minmax(0, 1fr); gap: 16px; padding: 16px; height: calc(100vh - 58px); }
35
- @media (max-width: 820px) { main { grid-template-columns: 1fr; height: auto; } .side { height: 520px; } .phone { height: 680px; } }
58
+ main { display: grid; grid-template-columns: minmax(0, 400px) minmax(0, 1fr); gap: 16px; padding: 16px; min-height: 0; }
59
+ @media (max-width: 820px) { body { height: auto; min-height: 100dvh; } main { grid-template-columns: 1fr; height: auto; } .side { height: 520px; } .phone { height: 680px; } }
36
60
 
37
61
  /* The phone */
38
62
  .phone { border-radius: 28px; overflow: hidden; display: flex; flex-direction: column; min-height: 0; border: 1px solid var(--line); box-shadow: 0 2px 10px rgba(0,0,0,.06); position: relative; }
@@ -233,24 +257,60 @@
233
257
  .log div { display: grid; grid-template-columns: 70px 1fr; gap: 8px; border-bottom: 1px dashed var(--line); padding: 2px 0; }
234
258
  .log .t { color: var(--quiet); }
235
259
  .tag { font-weight: 600; } .tag.inline { color: var(--good); } .tag.bg { color: var(--warn); } .tag.fold, .tag.ack { color: var(--fold); } .tag.bad { color: var(--bad); }
260
+ .auth-state { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; padding: 10px 14px; border-bottom: 1px solid var(--line, #ddd); font-size: 13px; }
261
+ .auth-state[hidden] { display: none; }
262
+ .auth-state a { color: var(--accent); text-underline-offset: 3px; }
263
+ .auth-state button { margin-left: auto; font-size: 12px; }
264
+ .local-cli-mode main { grid-template-columns: minmax(0, 1fr); }
265
+ .local-cli-mode .side { display: none; }
266
+ .local-cli-mode .person-picker, .local-cli-mode #debug { display: none; }
267
+ .cli-console { min-height: 0; display: flex; flex-direction: column; border: 1px solid #303844; border-radius: 12px; overflow: hidden; background: #11161e; color: #e2e8f0; font: 13px/1.65 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
268
+ .cli-bar { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; padding: 12px 18px; background: #1a212c; border-bottom: 1px solid #303844; }
269
+ .cli-bar b { display: block; font-size: 12px; }
270
+ .cli-bar small { color: #9caabd; font-size: 11px; }
271
+ .cli-console button { border: 1px solid #43516a; border-radius: 5px; background: #273243; color: #d0daea; padding: 6px 12px; cursor: pointer; font: inherit; font-size: 12px; }
272
+ .cli-console button:disabled { opacity: .5; cursor: default; }
273
+ .cli-console pre { flex: 1; min-height: 120px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; margin: 0; padding: 18px 20px; font: inherit; }
274
+ .cli-console .stderr { color: #c9bb9b; }
275
+ .cli-console .input { color: #b8dfc1; }
276
+ .cli-console .exit { display: block; color: #96a5ba; font-size: 11px; margin: 4px 0 16px; }
277
+ .cli-login { margin: 0 18px 12px; color: #9fc5ff; }
278
+ .cli-form { display: flex; align-items: center; gap: 10px; padding: 12px 18px; border-top: 1px solid #303844; background: #161d27; }
279
+ .cli-form > span { color: #b8dfc1; }
280
+ .cli-form input { flex: 1; min-width: 0; border: 0; outline: none; padding: 8px 0; background: transparent; color: #e2e8f0; font: inherit; }
281
+ .cli-form input::placeholder { color: #94a3b8; }
282
+ .cli-form:focus-within { box-shadow: inset 0 2px #719c7b; }
283
+ .cli-state { color: #94a3b8; padding: 0 18px 8px; font-size: 11px; }
284
+ @media (max-width: 820px) { .cli-console { height: 680px; } }
236
285
  </style>
237
286
  </head>
238
287
  <body>
239
- <div class="top">
240
- <h1>{{title}} <small>· Flow Framework playground</small></h1>
288
+ <header class="top">
289
+ <h1><span class="agent-title">{{title}}</span><small>Flow Framework playground</small></h1>
241
290
  <div class="tabs" id="tabs" role="group" aria-label="Platform"></div>
242
- <div class="spacer"></div>
243
- <select class="ghost play-only" id="who" aria-label="Test person" title="Who you are: one of the agent's test people, the same person on every channel"></select>
244
- <button class="ghost" id="debug" type="button" aria-pressed="false" title="Show the agent's tool calls in the conversation">Debug</button>
245
- <button class="ghost cloud-only" id="reset" type="button">New conversation</button>
246
- <button class="ghost play-only" id="clear" type="button" title="Forget this person: their history, schedules and everything the agent kept about them">Clear history</button>
247
- </div>
291
+ <div class="header-controls">
292
+ <label class="person-picker play-only" for="who"><span>Person</span><select class="ghost" id="who" aria-label="Person" title="Choose a saved test person, or start as a new signed-out user"></select></label>
293
+ <div class="header-actions">
294
+ <button class="ghost" id="debug" type="button" aria-pressed="false" title="Show the agent's tool calls in the conversation"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path d="m8 8-4 4 4 4m8-8 4 4-4 4m-3-11-2 14"/></svg>Debug</button>
295
+ <button class="ghost cloud-only" id="reset" type="button">New conversation</button>
296
+ <button class="ghost play-only" id="clear" type="button" title="Forget this person: their history, schedules and everything the agent kept about them"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13M10 10v7m4-7v7"/></svg>Clear history</button>
297
+ </div>
298
+ </div>
299
+ </header>
248
300
  <main>
301
+ <section class="cli-console" id="cliPanel" aria-label="Local CLI" hidden>
302
+ <div class="cli-bar"><div><b id="cliTitle">Local CLI</b><small>Runs on this computer · independent sign-in · no shell</small></div><button type="button" id="cliReset">New CLI session</button></div>
303
+ <pre id="cliOutput" role="log" aria-label="CLI output" aria-live="polite" tabindex="0"></pre>
304
+ <a class="cli-login" id="cliLogin" target="_blank" rel="noopener noreferrer" hidden>Open sign-in, then return here</a>
305
+ <form class="cli-form" id="cliForm"><span aria-hidden="true">$</span><input id="cliInput" autocomplete="off" spellcheck="false" aria-label="CLI input"><button type="submit" id="cliSend" disabled>Run ↵</button></form>
306
+ <div class="cli-state" id="cliState">Ready</div>
307
+ </section>
249
308
  <section class="phone" id="phone" aria-label="Simulator">
250
309
  <div class="bar" id="bar">
251
310
  <div class="avatar" id="avatar"></div>
252
311
  <div class="who"><b id="name">{{title}}</b><span id="sub"></span></div>
253
312
  </div>
313
+ <div class="auth-state" id="authState" hidden><span id="authLabel"></span><a id="signIn" hidden>Sign in</a><button class="ghost" id="startSignedOut" type="button">New signed-out user</button></div>
254
314
  <div class="thread" id="thread" aria-live="polite"></div>
255
315
  <div class="replying" id="replying"><b>Replying to</b><div id="replyingText"></div><button type="button" id="cancelReply" aria-label="Cancel reply">✕</button></div>
256
316
  <form class="compose" id="form">
@@ -358,8 +418,13 @@
358
418
  let person = params.get("person") || (PLAY ? recall("chinwag.person") : null) || "demo";
359
419
  let people = []; // the playground: the agent's test people
360
420
  let channels = []; // the playground: the agent's channels, each with the platform that plays it
421
+ let authentication = false;
422
+ let localCli = null;
423
+ let cliEpoch = 0, cliSeq = 0, cliRunning = false, cliWaiting = false;
424
+ let signedIn = true;
361
425
  let title = document.getElementById("name").textContent;
362
426
  const $ = (id) => document.getElementById(id);
427
+ const clearLabel = $("clear").lastChild;
363
428
  const TAPBACKS = [["❤️", "Loved"], ["👍", "Liked"], ["👎", "Disliked"], ["😂", "Laughed at"], ["‼️", "Emphasized"], ["❓", "Questioned"]];
364
429
  let platforms = [];
365
430
  let cards = [];
@@ -372,6 +437,9 @@
372
437
  const rows = new Map(); // message id → row element
373
438
 
374
439
  const where = () => PLAY ? { channel: current.channel, person } : { platform: current.id, person };
440
+ const isVisitor = () => authentication && person === "visitor";
441
+ const isLocalCli = () => PLAY && localCli && current?.channel === localCli.channel;
442
+ const draftKey = () => `chinwag.draft.${person}.${current.channel}`;
375
443
  const query = () => "?" + new URLSearchParams(where()).toString();
376
444
  const fmt = (ms) => ms == null ? "…" : ms < 1000 ? `${ms} ms` : `${(ms / 1000).toFixed(1)} s`;
377
445
  const clock = (at) => new Date(at).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
@@ -398,6 +466,29 @@
398
466
  }
399
467
  const tab = PLAY ? current.channel : current.id;
400
468
  for (const b of document.querySelectorAll(".tab")) b.setAttribute("aria-pressed", String(b.dataset.id === tab));
469
+ cliEpoch++;
470
+ const cli = isLocalCli();
471
+ document.body.classList.toggle("local-cli-mode", !!cli);
472
+ $("phone").hidden = !!cli;
473
+ $("cliPanel").hidden = !cli;
474
+ $("who").disabled = !!cli;
475
+ $("debug").disabled = !!cli;
476
+ clearLabel.textContent = cli ? "Clear output" : "Clear history";
477
+ $("clear").title = cli ? "Clear the visible CLI output; keep this session's sign-in" : "Forget this person: their history, schedules and everything the agent kept about them";
478
+ if (cli) {
479
+ ++opened;
480
+ source?.close(); source = null;
481
+ $("cliTitle").textContent = `${localCli.name} · local CLI`;
482
+ $("cliInput").placeholder = `${localCli.name} --help`;
483
+ $("cliSend").disabled = true;
484
+ cliSeq = 0;
485
+ $("cliOutput").textContent = "";
486
+ $("cliLogin").hidden = true;
487
+ void pollCli(cliEpoch);
488
+ return;
489
+ }
490
+ signedIn = !isVisitor();
491
+ $("authState").hidden = !isVisitor();
401
492
  void refreshPerson();
402
493
  $("phone").className = "phone p-" + current.id;
403
494
  $("avatar").textContent = title.trim().charAt(0).toUpperCase();
@@ -414,7 +505,7 @@
414
505
  $("text").placeholder = current.id === "sms" ? "Text message" : "Message";
415
506
  updateCounter();
416
507
  if (!current.complete) sysnote("This simulator doesn't model every feature of the real platform yet.");
417
- connect();
508
+ connect().catch(() => sysnote("Could not load this conversation. Try again.", true));
418
509
  }
419
510
 
420
511
  function sysnote(text, bad) {
@@ -424,6 +515,61 @@
424
515
  insert(el);
425
516
  }
426
517
 
518
+ function cliOutput(stream, text) {
519
+ const output = $("cliOutput");
520
+ const follow = output.scrollHeight - output.scrollTop - output.clientHeight < 60;
521
+ const chunk = document.createElement("span");
522
+ chunk.className = stream;
523
+ // The CLI writes plain text/ANSI, never HTML. This viewport does not emulate a full TTY.
524
+ chunk.textContent = text.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "").replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "");
525
+ output.appendChild(chunk);
526
+ while (output.textContent.length > 128 * 1024 && output.children.length > 1) output.firstChild.remove();
527
+ if (follow) output.scrollTop = output.scrollHeight;
528
+ }
529
+
530
+ async function pollCli(epoch) {
531
+ if (epoch !== cliEpoch || !isLocalCli()) return;
532
+ try {
533
+ const res = await fetch(BASE + `/api/cli?after=${cliSeq}`);
534
+ if (!res.ok) throw new Error("Could not read CLI output.");
535
+ const state = await res.json();
536
+ if (epoch !== cliEpoch) return;
537
+ for (const event of state.events) {
538
+ if (event.stream === "login") {
539
+ const url = new URL(event.text);
540
+ if (["http:", "https:"].includes(url.protocol)) { $("cliLogin").href = url.href; $("cliLogin").hidden = false; }
541
+ } else {
542
+ cliOutput(event.stream, event.stream === "exit" ? `exit ${event.text}\n` : event.text);
543
+ if (event.stream === "exit") $("cliLogin").hidden = true;
544
+ }
545
+ }
546
+ cliSeq = state.seq; cliRunning = state.running; cliWaiting = state.waiting;
547
+ $("cliSend").disabled = cliRunning && !cliWaiting;
548
+ $("cliSend").textContent = cliRunning ? "Send ↵" : "Run ↵";
549
+ $("cliInput").placeholder = cliWaiting ? "Type a reply…" : cliRunning ? "Command running…" : `${localCli.name} --help`;
550
+ $("cliState").textContent = cliWaiting ? "Waiting for input" : cliRunning ? "Running" : "Ready · use auth login to sign in, chat to talk, or --help for commands";
551
+ } catch { if (epoch === cliEpoch) $("cliState").textContent = "Connection interrupted · reconnecting…"; }
552
+ if (epoch === cliEpoch && isLocalCli()) setTimeout(() => pollCli(epoch), 400);
553
+ }
554
+
555
+ $("cliForm").addEventListener("submit", async (event) => {
556
+ event.preventDefault();
557
+ const text = $("cliInput").value;
558
+ if (!text.trim()) return;
559
+ $("cliSend").disabled = true;
560
+ try {
561
+ const res = await fetch(BASE + "/api/cli/input", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text }) });
562
+ if (res.ok) $("cliInput").value = "";
563
+ else cliOutput("stderr", `${(await res.json()).error || "Could not send input."}\n`);
564
+ } catch { cliOutput("stderr", "Could not send input. Check the connection before trying again.\n"); }
565
+ });
566
+ $("cliReset").addEventListener("click", async () => {
567
+ if (!confirm("End this CLI session and remove its saved sign-in? Account chat history stays with the agent.")) return;
568
+ const res = await fetch(BASE + "/api/cli/reset", { method: "POST" });
569
+ if (res.ok) { $("cliInput").value = ""; setPlatform(current.channel); }
570
+ else cliOutput("stderr", "Could not reset the CLI session.\n");
571
+ });
572
+
427
573
  function insert(el) {
428
574
  const thread = $("thread");
429
575
  thread.insertBefore(el, typingEl && typingEl.isConnected ? typingEl : null);
@@ -486,8 +632,12 @@
486
632
  }
487
633
  }
488
634
  const body = document.createElement("span");
489
- if (current.formatting === "whatsapp") body.innerHTML = whatsappHtml(line.text);
490
- else body.textContent = line.text;
635
+ // Channels without link buttons carry the URL in text. A real auth link must still open login.
636
+ const authURL = line.authentication && !line.link ? /\n(https?:\/\/\S+)$/.exec(line.text) : null;
637
+ const link = line.link || (authURL ? { label: "Sign in", url: authURL[1] } : null);
638
+ const text = authURL ? line.text.slice(0, authURL.index) : line.text;
639
+ if (current.formatting === "whatsapp") body.innerHTML = whatsappHtml(text);
640
+ else body.textContent = text;
491
641
  b.appendChild(body);
492
642
  if (tpl && tpl.footer) { const f = document.createElement("span"); f.className = "tpl-foot"; f.textContent = tpl.footer; b.appendChild(f); }
493
643
  if (current.id === "whatsapp") {
@@ -540,16 +690,21 @@
540
690
  btns.appendChild(btn);
541
691
  row.appendChild(btns);
542
692
  }
543
- if (line.link) {
544
- // A button that opens a page (WhatsApp's call-to-action URL button). Nothing is really opened.
693
+ if (link) {
694
+ // A button that opens a page (WhatsApp's call-to-action URL button). Authentication opens the real login. Other links use a simulated screen.
545
695
  const btns = document.createElement("div");
546
696
  btns.className = "btns";
547
697
  const btn = document.createElement("button");
548
698
  btn.type = "button";
549
- btn.textContent = "↗ " + line.link.label;
550
- btn.title = line.link.url;
551
- const url = line.link.url;
552
- btn.addEventListener("click", () => openScreen(cards.find((c) => url.includes(c.match)) || { label: line.link.label, kind: "page" }, url));
699
+ btn.textContent = "↗ " + link.label;
700
+ btn.title = link.url;
701
+ const url = link.url;
702
+ btn.addEventListener("click", () => {
703
+ if (line.authentication) {
704
+ const target = new URL(url, location.href);
705
+ if (["https:", "http:"].includes(target.protocol)) location.assign(target.href);
706
+ } else openScreen(cards.find((c) => url.includes(c.match)) || { label: link.label, kind: "page" }, url);
707
+ });
553
708
  btns.appendChild(btn);
554
709
  row.appendChild(btns);
555
710
  }
@@ -899,7 +1054,19 @@
899
1054
  const mine = ++opened;
900
1055
  if (source) { source.close(); source = null; }
901
1056
  if (replayData) { showReplay(replayData); return; }
902
- const { lines, seq } = await (await fetch(BASE + "/api/transcript" + query())).json();
1057
+ if (isVisitor()) {
1058
+ const response = await fetch(BASE + "/api/auth" + query());
1059
+ if (!response.ok) throw new Error("Could not check sign-in");
1060
+ const state = await response.json();
1061
+ if (mine !== opened) return;
1062
+ showAuth(state);
1063
+ try { $("text").value = sessionStorage.getItem(draftKey()) || $("text").value; } catch {}
1064
+
1065
+ }
1066
+ const response = await fetch(BASE + "/api/transcript" + query());
1067
+ if (isVisitor() && response.status === 401) { showAuth(await response.json()); return; }
1068
+ if (!response.ok) throw new Error("Could not load history");
1069
+ const { lines, seq } = await response.json();
903
1070
  if (mine !== opened) return;
904
1071
  for (const l of lines) showLine(l, false);
905
1072
  if (!lines.length) {
@@ -975,11 +1142,37 @@
975
1142
  }
976
1143
 
977
1144
  async function send(text, extra = {}) {
1145
+ if (isVisitor() && !signedIn) { try { sessionStorage.setItem(draftKey(), text); } catch {} }
978
1146
  const body = { ...where(), text, ...extra };
979
1147
  if (!body.replyTo && replyTo) body.replyTo = replyTo.id;
980
1148
  setReply(null);
981
- await fetch(BASE + "/api/messages", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
1149
+ const response = await fetch(BASE + "/api/messages", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
1150
+ if (!response.ok) {
1151
+ $("text").value = text;
1152
+ if (isVisitor() && response.status === 401) showAuth(await response.json());
1153
+ sysnote("Your message was not sent. Check sign-in and try again.", true);
1154
+ } else if (signedIn) { try { sessionStorage.removeItem(draftKey()); } catch {} }
1155
+ }
1156
+
1157
+ function showAuth(state) {
1158
+ signedIn = state.signedIn === true;
1159
+ $("authState").hidden = false;
1160
+ $("authLabel").textContent = state.label || (signedIn ? "Signed in" : "Signed out");
1161
+ $("signIn").hidden = signedIn || !state.signInUrl;
1162
+ $("signIn").removeAttribute("href");
1163
+ if (state.signInUrl) {
1164
+ const target = new URL(state.signInUrl, location.href);
1165
+ if (["https:", "http:"].includes(target.protocol)) $("signIn").href = target.href;
1166
+ }
1167
+ const option = $("who").querySelector('option[value="visitor"]');
1168
+ if (option) option.textContent = signedIn ? "Connected user" : "New user · signed out";
982
1169
  }
1170
+ $("signIn").addEventListener("click", () => { try { sessionStorage.setItem(draftKey(), $("text").value); } catch {} });
1171
+ $("startSignedOut").addEventListener("click", async () => {
1172
+ const response = await fetch(BASE + "/api/auth/reset" + query(), { method: "POST", headers: { "content-type": "application/json" }, body: "{}" });
1173
+ if (!response.ok) { sysnote("Could not reset this browser's sign-in.", true); return; }
1174
+ setPlatform(current.channel);
1175
+ });
983
1176
 
984
1177
  // The person's message is shown at once; the agent's id for it (for ticks, replies and
985
1178
  // reactions) arrives a moment later.
@@ -1037,26 +1230,46 @@
1037
1230
  });
1038
1231
  $("cancelReply").addEventListener("click", () => setReply(null));
1039
1232
  $("reset").addEventListener("click", () => fetch(BASE + "/api/reset" + query(), { method: "POST" }));
1040
- $("clear").addEventListener("click", () => {
1041
- const who = people.find((p) => p.phone === person);
1042
- if (who && confirm(`Forget ${who.name}? Their history, schedules and everything the agent kept about them go, on every channel.`)) {
1043
- fetch(BASE + "/api/clear?" + new URLSearchParams({ person }).toString(), { method: "POST" });
1044
- }
1233
+ $("clear").addEventListener("click", async () => {
1234
+ if (isLocalCli()) { $("cliOutput").textContent = ""; return; }
1235
+ const name = isVisitor() ? (signedIn ? "this account" : "this visitor") : people.find((p) => p.phone === person)?.name;
1236
+ if (!name || !confirm(`Forget ${name}? Their history, schedules and everything the agent kept about them go, on every channel.`)) return;
1237
+ try {
1238
+ const response = await fetch(BASE + "/api/clear" + query(), { method: "POST" });
1239
+ if (!response.ok) throw new Error("clear failed");
1240
+ } catch { sysnote("Could not clear this conversation. Try again.", true); }
1045
1241
  });
1046
1242
  document.addEventListener("click", (e) => {
1047
1243
  if (!e.target.closest(".picker") && !e.target.closest(".actions")) document.querySelectorAll(".picker").forEach((p) => p.remove());
1048
1244
  });
1049
1245
 
1050
1246
  function tabFor(id, label, p) {
1247
+ const known = platforms.find(platform => platform.id === id.toLowerCase());
1248
+ const kind = id.toLowerCase() === "terminal" ? "terminal" : (known?.id ?? p.id);
1249
+ const name = known?.name ?? (kind === "terminal" ? "Terminal" : label);
1051
1250
  const b = document.createElement("button");
1052
1251
  b.className = "tab";
1053
1252
  b.type = "button";
1054
1253
  b.dataset.id = id;
1055
- const img = document.createElement("img");
1056
- img.src = `https://cdn.simpleicons.org/${p.icon}`;
1057
- img.alt = "";
1058
- b.append(img, document.createTextNode(label));
1059
- if (!p.complete) { const s = document.createElement("small"); s.textContent = "partial"; b.appendChild(s); }
1254
+ b.dataset.label = name;
1255
+ b.setAttribute("aria-label", name);
1256
+ b.title = name + (p.complete ? "" : " · partial simulation");
1257
+ if (!p.complete) b.setAttribute("aria-description", "Some platform features are not simulated yet.");
1258
+ const drawings = {
1259
+ web: '<circle cx="12" cy="12" r="9"/><ellipse cx="12" cy="12" rx="4" ry="9"/><path d="M3 12h18M5 6h14M5 18h14"/>',
1260
+ terminal: '<rect x="2" y="3" width="20" height="18" rx="4"/><path d="m6 8 4 4-4 4m7 0h5"/>',
1261
+ imessage: '<rect x="1" y="1" width="22" height="22" rx="6" fill="currentColor" stroke="none"/><path d="M19 11.5c0 3.3-3.1 6-7 6-.9 0-1.7-.1-2.5-.4L6 19l.8-3.2C5.7 14.7 5 13.2 5 11.5c0-3.3 3.1-6 7-6s7 2.7 7 6Z" fill="white" stroke="none"/>',
1262
+ sms: '<rect x="1" y="1" width="22" height="22" rx="6" fill="currentColor" stroke="none"/><path d="M6 6h12v9H9l-3 3V6Zm3 3h6m-6 3h4" stroke="white" stroke-width="1.5"/>',
1263
+ };
1264
+ if (drawings[kind]) {
1265
+ const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
1266
+ icon.setAttribute("viewBox", "0 0 24 24"); icon.setAttribute("fill", "none"); icon.setAttribute("stroke", "currentColor"); icon.setAttribute("stroke-width", "1.6"); icon.setAttribute("aria-hidden", "true");
1267
+ icon.classList.add("tab-icon", kind); icon.innerHTML = drawings[kind]; b.appendChild(icon);
1268
+ } else {
1269
+ const img = document.createElement("img"); img.src = `https://cdn.simpleicons.org/${known?.icon ?? p.icon}`; img.alt = ""; b.appendChild(img);
1270
+ }
1271
+ const text = document.createElement("span"); text.className = "tab-label"; text.appendChild(document.createTextNode(name));
1272
+ b.appendChild(text);
1060
1273
  const badge = document.createElement("span"); badge.className = "unread"; badge.hidden = true; badge.setAttribute("aria-label", "new message"); b.appendChild(badge);
1061
1274
  b.addEventListener("click", () => setPlatform(id));
1062
1275
  $("tabs").appendChild(b);
@@ -1097,7 +1310,7 @@
1097
1310
  return li;
1098
1311
  }
1099
1312
  async function refreshPerson() {
1100
- if (!PLAY || !panel.person || !current) return;
1313
+ if (!PLAY || !panel.person || !current || isLocalCli()) return;
1101
1314
  const res = await fetch(BASE + "/api/person" + query());
1102
1315
  if (!res.ok) return;
1103
1316
  const { now, contact, outbox, lastOut = {} } = await res.json();
@@ -1145,10 +1358,12 @@
1145
1358
  const badge = b.querySelector(".unread");
1146
1359
  if (!badge) continue;
1147
1360
  const stored = recall(key(ch));
1361
+ b.setAttribute("aria-label", b.dataset.label);
1148
1362
  if (ch === current.channel || stored === null) { remember(key(ch), String(last)); badge.hidden = true; continue; }
1149
1363
  const unread = times.filter((t) => t > Number(stored)).length;
1150
1364
  badge.hidden = unread === 0;
1151
1365
  badge.textContent = unread > 9 ? "9+" : String(unread);
1366
+ if (unread) b.setAttribute("aria-label", `${b.dataset.label}, ${unread} new ${unread === 1 ? "message" : "messages"}`);
1152
1367
  }
1153
1368
  }
1154
1369
 
@@ -1201,12 +1416,15 @@
1201
1416
  }
1202
1417
 
1203
1418
  (async () => {
1204
- ({ platforms, cards, people = [], channels = [], templates = [], panel = panel } = await (await fetch(BASE + "/api/platforms")).json());
1419
+ ({ platforms, cards, people = [], channels = [], templates = [], panel = panel, authentication = false, localCli = null } = await (await fetch(BASE + "/api/platforms")).json());
1205
1420
  if (PLAY) {
1206
1421
  if (!channels.length) return empty("This agent has no channels. Declare them in <code>createAgent({ channels: [...] })</code> (whatsapp(), sms(), customUI()) to test it here.");
1207
- if (!people.length) return empty("This agent has no test people. Declare who may be played here in <code>createAgent({ testPeople: [{ name, phone }] })</code>.");
1208
- if (!people.some((p) => p.phone === person)) person = people[0].phone;
1422
+ if (!people.length && !authentication) return empty("This agent has no test people. Declare who may be played here in <code>createAgent({ testPeople: [{ name, phone }] })</code>.");
1423
+ if (!people.some((p) => p.phone === person) && !(authentication && person === "visitor")) person = authentication ? "visitor" : people[0].phone;
1209
1424
  remember("chinwag.person", person);
1425
+ if (authentication) {
1426
+ const option = document.createElement("option"); option.value = "visitor"; option.textContent = "New user · signed out"; $("who").appendChild(option);
1427
+ }
1210
1428
  for (const p of people) {
1211
1429
  const o = document.createElement("option");
1212
1430
  o.value = p.phone;
@@ -1,6 +1,7 @@
1
1
  import { type IncomingMessage, type ServerResponse } from "node:http";
2
2
  import { type Attachment, type ChannelInfo, type ChannelSummary, type Deliver, type EventSink, type FilledTemplate, type Incoming, type ListRow, type PlaygroundSink, type TemplateDefinition, type TestPerson, type TranscriptEntry } from "../../src/index.ts";
3
3
  import type { Agent } from "../../src/agent.ts";
4
+ import type { CliConfig } from "../../cli/src/commands.ts";
4
5
  /** What the test cloud's simulators need from an agent. */
5
6
  export interface AgentConnection {
6
7
  /** A person's message; returns its id, or null when the agent answered it without a turn. */
@@ -35,6 +36,8 @@ export interface PlaygroundAgent {
35
36
  messageId: string;
36
37
  } | null>;
37
38
  testConversation(phone: string): Promise<string | null>;
39
+ simulateVisitor?: Agent["simulateVisitor"];
40
+ visitorConversation?: Agent["visitorConversation"];
38
41
  transcript(conversationId: string): Promise<TranscriptEntry[]>;
39
42
  reacted(conversationId: string, emoji: string, toText: string): void;
40
43
  forgetPerson(conversationId: string): Promise<void>;
@@ -81,6 +84,21 @@ export interface SimulatorsOptions {
81
84
  };
82
85
  /** The agent's clock (playgroundClock(), passed to createAgent as `now`): the Person panel can move it. */
83
86
  clock?: PlaygroundClock;
87
+ /**
88
+ * Adds "New user · signed out" to the person picker. `visitor` is an opaque browser identity,
89
+ * scoped to the channel. Return the account `key` only after the application's normal auth
90
+ * verifies it, plus its channel `address` if different from visitor. Keep credentials server-side.
91
+ * Existing declared test people keep their current identity shortcut.
92
+ */
93
+ authentication?: (input: {
94
+ channel: ChannelSummary;
95
+ visitor: string;
96
+ }) => Promise<{
97
+ key?: string;
98
+ address?: string;
99
+ label?: string;
100
+ signInUrl?: string;
101
+ }>;
84
102
  }
85
103
  export interface LinkCard {
86
104
  /** Text the link's URL contains, such as "/video/". */
@@ -95,17 +113,25 @@ type Listening = {
95
113
  close(): Promise<void>;
96
114
  url: string;
97
115
  };
116
+ export interface LocalCliOptions {
117
+ /** The team's actual defineCli configuration. The browser cannot override its endpoints. */
118
+ config: CliConfig;
119
+ /** The channel tab that opens this runner, e.g. "terminal". */
120
+ channel: string;
121
+ }
98
122
  export interface Playground {
99
123
  /** Pass to the agent's `events`: the trace and tool calls of test people's conversations. */
100
124
  events: EventSink;
101
125
  /** Serves the playground on 127.0.0.1 only; a token is optional there. */
102
126
  listen(agent: PlaygroundAgent, port: number, options?: {
103
127
  token?: string;
128
+ localCli?: LocalCliOptions;
104
129
  }): Promise<Listening>;
105
130
  /** The playground as a request handler under `base`, for a deployed agent's own server: a token is required. */
106
131
  handler(agent: PlaygroundAgent, options: {
107
132
  token: string;
108
133
  base?: string;
134
+ localCli?: LocalCliOptions;
109
135
  }): Handler;
110
136
  }
111
137
  export interface TestCloudSimulators {
@@ -140,6 +166,7 @@ export type Line = {
140
166
  label: string;
141
167
  url: string;
142
168
  };
169
+ authentication?: boolean;
143
170
  /** A photo above the text, which is its caption. */
144
171
  image?: {
145
172
  url: string;