playthrough 0.4.1 → 0.4.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.
package/README.md CHANGED
@@ -4,13 +4,7 @@ The agent says it's done. You decide if it's good.
4
4
 
5
5
  Playthrough records your coding agent's change the way a person would use it, before and after, so you can watch it instead of setting it up by hand. Point at what's wrong, right on the recording, and the next round answers it there. Take over any moment and carry on in the real app. It runs on your machine, with any coding agent that has a shell.
6
6
 
7
- ## How a review goes
8
-
9
- 1. You ask your agent for a change, and to show it to you with Playthrough.
10
- 2. Before it touches the code, the agent writes a flow for each thing a person does that the change touches, and Playthrough keeps the app as it is: Before.
11
- 3. The agent makes the change, and Playthrough records every flow, on the app as it is now and on Before, as far as the app went before. It finds what changed itself, including what the agent didn't mention, and the agent says why each change is there.
12
- 4. You watch. Leave a note on what's wrong, where it's wrong; compare with Before; take over and try it yourself. Then send.
13
- 5. The agent answers each note in the next round, on the spot the note is on. You check its answers, and accept once every flow looks good.
7
+ To see a review before you install anything, play the one at [playthrough.sh](https://playthrough.sh).
14
8
 
15
9
  ## Try it
16
10
 
@@ -28,7 +22,15 @@ Reviews stay in `.playthrough/` in your app's repo, recordings and all; nothing
28
22
 
29
23
  ## On the example
30
24
 
31
- `example/` in this repo is a small invoices app to try it on before your own. Clone the repo, run `npm install`, `npx playwright install chromium` and `npm run build`, and from `example/`, ask your agent to show only the overdue invoices, and to show you the change with `npx playthrough`.
25
+ `example/` in this repo is a small invoices app to try it on before your own. Clone the repo, run `npm install`, `npx playwright install chromium` and `npm run build`, and from `example/`, ask your agent to show how much is overdue in total under the Invoices heading, and to show you the change with `npx playthrough`.
26
+
27
+ ## How a review goes
28
+
29
+ 1. You ask your agent for a change, and to show it to you with Playthrough.
30
+ 2. Before it touches the code, the agent writes a flow for each thing a person does that the change touches, and Playthrough keeps the app as it is: Before.
31
+ 3. The agent makes the change, and Playthrough records every flow, on the app as it is now and on Before, as far as the app went before. It finds what changed itself, including what the agent didn't mention, and the agent says why each change is there.
32
+ 4. You watch. Leave a note on what's wrong, where it's wrong; compare with Before; take over and try it yourself. Then send.
33
+ 5. The agent answers each note in the next round, on the spot the note is on. You check its answers, and accept once every flow looks good.
32
34
 
33
35
  ## In the viewer
34
36
 
package/package.json CHANGED
@@ -1,7 +1,22 @@
1
1
  {
2
2
  "name": "playthrough",
3
- "version": "0.4.1",
4
- "description": "Review an AI agent's work on a web app by watching recordings of it.",
3
+ "version": "0.4.3",
4
+ "description": "Review what your coding agent built in your web app by watching recordings of it.",
5
+ "keywords": [
6
+ "coding-agent",
7
+ "claude-code",
8
+ "cursor",
9
+ "codex",
10
+ "ai-agents",
11
+ "code-review",
12
+ "ui-review",
13
+ "playwright",
14
+ "recording",
15
+ "human-in-the-loop",
16
+ "developer-tools",
17
+ "web-app"
18
+ ],
19
+ "homepage": "https://playthrough.sh",
5
20
  "type": "module",
6
21
  "license": "Apache-2.0",
7
22
  "repository": {
@@ -6,7 +6,7 @@ import { createRequire } from "node:module";
6
6
  import { copyFileSync, cpSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
7
7
  import { execFileSync } from "node:child_process";
8
8
  import { readFile, readdir, stat, writeFile } from "node:fs/promises";
9
- import { createHash } from "node:crypto";
9
+ import { createHash, randomBytes } from "node:crypto";
10
10
  import { constants, networkInterfaces, tmpdir, userInfo } from "node:os";
11
11
  import { dirname, extname, join, normalize, resolve, sep } from "node:path";
12
12
  import { chromium, request } from "@playwright/test";
@@ -557,14 +557,17 @@ async function takeOver$1(repo, id, flowName, stepName) {
557
557
  throw new Refused([`Take over which flow? playthrough take-over ${id} "<flow>" ["<step>"]\nIts flows: ${list(round.flows.map((flow) => quote(flow.name)))}.`]);
558
558
  }
559
559
  console.log(`Running ${quote(flowName)} to ${stepName ? `the end of ${quote(stepName)}` : "its end"}, on the app as it is now…`);
560
- const { port, moment, seconds } = await getReady(repo, id, flowName, { step: stepName });
560
+ const { port, moment, seconds } = await getReady(repo, id, flowName, {
561
+ step: stepName,
562
+ network: true
563
+ });
561
564
  const here = `http://localhost:${port}/take-over/${moment}`;
562
565
  const away = addresses().map((address) => `http://${address}:${port}/take-over/${moment}`);
563
566
  console.log(`
564
567
  Ready in ${seconds}s. Carry on from there in your browser: ${here}${away.length ? `\nOr on your phone, on the same network: ${list(away)}` : ""}
565
568
  Each address hands over the same copy, and the app is passed on through Playthrough while this runs. Ctrl+C ends it.`);
566
569
  }
567
- async function getReady(repo, id, flowName, { step: stepName, actions, did, ahead } = {}) {
570
+ async function getReady(repo, id, flowName, { step: stepName, actions, did, ahead, network } = {}) {
568
571
  const round = await recorded(repo, id);
569
572
  const flow = round.flows.find((flow) => flow.name === flowName);
570
573
  if (!flow) throw new Refused([`${id} has no flow called ${quote(flowName)}. Its flows: ${list(round.flows.map((flow) => quote(flow.name)))}.`]);
@@ -584,10 +587,9 @@ async function getReady(repo, id, flowName, { step: stepName, actions, did, ahea
584
587
  });
585
588
  passing ??= {
586
589
  moments: /* @__PURE__ */ new Map(),
587
- port: passOn(app, (moment) => passing.moments.get(moment)),
588
- numbered: 0
590
+ port: passOn(app, (moment) => passing.moments.get(moment), network)
589
591
  };
590
- const moment = String(++passing.numbered);
592
+ const moment = randomBytes(12).toString("base64url");
591
593
  passing.moments.set(moment, did?.length ? {
592
594
  ...handed,
593
595
  did
@@ -625,11 +627,13 @@ async function recorded(repo, id) {
625
627
  if (!round) throw new Refused([`${id} has nothing recorded yet, so there's nothing to take over. Record it first: playthrough record ${id}`]);
626
628
  return round;
627
629
  }
628
- function passOn(app, handed) {
630
+ function passOn(app, handed, network) {
629
631
  const target = new URL(app.url);
630
632
  const server = createServer((request, response) => {
633
+ if (!named(request)) return turnAway(response);
631
634
  const [first, moment] = new URL(request.url ?? "/", "http://playthrough").pathname.split("/").filter(Boolean);
632
- if (first === "take-over") return handOver(handed(moment ?? ""), response);
635
+ if (first === "take-over") return handOver(handed(moment ?? ""), response, !fromThisMachine(request));
636
+ if (!passed(request)) return turnAway(response);
633
637
  const page = /text\/html/.test(request.headers.accept ?? "");
634
638
  const headers = asIfDirect(request, target);
635
639
  if (page) delete headers["accept-encoding"];
@@ -640,7 +644,7 @@ function passOn(app, handed) {
640
644
  path: request.url,
641
645
  headers
642
646
  }, (answer) => {
643
- const told = fromTheApp(answer, target);
647
+ const told = fromTheApp(answer, target, request);
644
648
  if (!page || !/text\/html/.test(String(answer.headers["content-type"])) || answer.headers["content-encoding"]) {
645
649
  response.writeHead(answer.statusCode ?? 502, told);
646
650
  answer.pipe(response);
@@ -662,6 +666,7 @@ function passOn(app, handed) {
662
666
  request.pipe(forward);
663
667
  });
664
668
  server.on("upgrade", (request, socket, head) => {
669
+ if (!named(request) || !passed(request)) return void socket.destroy();
665
670
  const upstream = connect(Number(target.port || 80), target.hostname, () => {
666
671
  const headers = Object.entries(asIfDirect(request, target)).flatMap(([name, value]) => [value ?? []].flat().map((one) => `${name}: ${one}`));
667
672
  upstream.write(`${request.method} ${request.url} HTTP/1.1\r\n${headers.join("\r\n")}\r\n\r\n`);
@@ -671,7 +676,15 @@ function passOn(app, handed) {
671
676
  upstream.on("error", () => socket.destroy());
672
677
  socket.on("error", () => upstream.destroy());
673
678
  });
674
- return listen(server, PORT$1, "0.0.0.0");
679
+ return listen(server, PORT$1, network ? "0.0.0.0" : "127.0.0.1");
680
+ }
681
+ const named = (request) => thisMachine(request.headers.host) || addresses().includes(request.headers.host?.replace(/:\d+$/, "") ?? "");
682
+ const PASS = `playthrough-pass=${randomBytes(16).toString("base64url")}`;
683
+ const fromThisMachine = (request) => /^(127\.|::1$|::ffff:127\.)/.test(request.socket.remoteAddress ?? "");
684
+ const passed = (request) => fromThisMachine(request) || !!request.headers.cookie?.split(/;\s*/).includes(PASS);
685
+ function turnAway(response) {
686
+ response.writeHead(403, { "content-type": "text/plain; charset=utf-8" });
687
+ response.end("Playthrough passes the app on to this machine, and to whoever opened the address of a moment taken over. Open that address first.");
675
688
  }
676
689
  function asIfDirect(request, target) {
677
690
  const headers = {
@@ -681,6 +694,9 @@ function asIfDirect(request, target) {
681
694
  const from = `http://${request.headers.host}`;
682
695
  if (headers.origin) headers.origin = target.origin;
683
696
  if (headers.referer?.startsWith(from)) headers.referer = target.origin + headers.referer.slice(from.length);
697
+ const cookie = headers.cookie?.split(/;\s*/).filter((one) => one !== PASS).join("; ");
698
+ if (cookie) headers.cookie = cookie;
699
+ else delete headers.cookie;
684
700
  return headers;
685
701
  }
686
702
  const recorder = join(dirname(createRequire(import.meta.url).resolve("@rrweb/record")), "record.umd.min.cjs");
@@ -1023,17 +1039,19 @@ const SAYS = `<script>(() => {
1023
1039
  };
1024
1040
  addEventListener("load", () => setTimeout(settle, 50));
1025
1041
  })();<\/script>`;
1026
- function fromTheApp(answer, target) {
1042
+ function fromTheApp(answer, target, request) {
1027
1043
  const headers = { ...answer.headers };
1028
1044
  delete headers["x-frame-options"];
1029
- headers["access-control-allow-origin"] = "*";
1045
+ const origin = request.headers.origin;
1046
+ if (origin && URL.canParse(origin) && thisMachine(new URL(origin).host)) headers["access-control-allow-origin"] = origin;
1047
+ headers.vary = [headers.vary, "Origin"].filter(Boolean).join(", ");
1030
1048
  const policy = headers["content-security-policy"];
1031
1049
  if (policy) headers["content-security-policy"] = [policy].flat().map((rule) => rule.replace(/frame-ancestors[^;]*;?/i, ""));
1032
1050
  if (headers.location?.startsWith(target.origin)) headers.location = headers.location.slice(target.origin.length) || "/";
1033
1051
  if (headers["set-cookie"]) headers["set-cookie"] = headers["set-cookie"].map((cookie) => cookie.replace(/;\s*Domain=[^;]*/i, ""));
1034
1052
  return headers;
1035
1053
  }
1036
- function handOver(handed, response) {
1054
+ function handOver(handed, response, elsewhere) {
1037
1055
  if (!handed) {
1038
1056
  response.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
1039
1057
  response.end("There's no moment ready at this address. Take it over again from the viewer, or with playthrough take-over.");
@@ -1043,6 +1061,7 @@ function handOver(handed, response) {
1043
1061
  const again = handed.again ?? [];
1044
1062
  const { pathname, search, hash } = new URL(url);
1045
1063
  const setCookies = cookies.map(({ name, value, path, expires, httpOnly }) => `${name}=${value}; Path=${path}; SameSite=Lax${httpOnly ? "; HttpOnly" : ""}${expires > 0 ? `; Expires=${(/* @__PURE__ */ new Date(expires * 1e3)).toUTCString()}` : ""}`);
1064
+ if (elsewhere) setCookies.push(`${PASS}; Path=/; SameSite=Lax; HttpOnly`);
1046
1065
  const keep = JSON.stringify({
1047
1066
  local,
1048
1067
  session,
@@ -1112,6 +1131,8 @@ async function open(repo, id) {
1112
1131
  console.log(`Playthrough is open at http://localhost:${port}/${id ?? ""}`);
1113
1132
  }
1114
1133
  async function answer(repo, page, you, request, response) {
1134
+ const stranger = fromElsewhere(request);
1135
+ if (stranger) return json(response, 403, { problems: [stranger] });
1115
1136
  const reviews = repo.reviews;
1116
1137
  const path = decodeURIComponent(new URL(request.url ?? "/", "http://localhost").pathname);
1117
1138
  const [api, collection, id, ...rest] = path.split("/").filter(Boolean);
@@ -1244,6 +1265,12 @@ async function serve(page, path, response) {
1244
1265
  });
1245
1266
  response.end(await readFile(join(page, "index.html")));
1246
1267
  }
1268
+ function fromElsewhere(request) {
1269
+ const { host, origin } = request.headers;
1270
+ if (!thisMachine(host)) return `Playthrough answers only at localhost, not at “${host}”. Open http://localhost:${request.socket.localPort}/.`;
1271
+ if (origin !== void 0 && origin !== `http://${host}`) return `Only Playthrough's own page can ask this, not ${origin}.`;
1272
+ }
1273
+ const thisMachine = (host) => /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i.test(host ?? "");
1247
1274
  async function body(request) {
1248
1275
  const chunks = [];
1249
1276
  for await (const chunk of request) chunks.push(chunk);
@@ -1717,31 +1744,38 @@ function done({ does, with: given, what }) {
1717
1744
  const share = (fraction) => `${Math.round(fraction * 100)}%`;
1718
1745
  // src/playthrough.ts
1719
1746
  const HELP = readFileSync(new URL("../INSTRUCTIONS.md", import.meta.url), "utf8").trimEnd();
1720
- const [command, review, ...words] = process.argv.slice(2);
1747
+ const given = process.argv.slice(2);
1748
+ const [command, review, ...words] = given;
1721
1749
  const needs = (usage) => new Refused([`That needs more: ${usage}`]);
1750
+ const takes = {
1751
+ start: [2, `playthrough start <review> "<title>" "<what was asked>"`],
1752
+ record: [0, "playthrough record <review>"],
1753
+ wait: [0, "playthrough wait <review>"],
1754
+ walk: [0, "playthrough walk <review>"],
1755
+ open: [0, "playthrough open [<review>]"],
1756
+ "take-over": [2, `playthrough take-over <review> "<flow>" ["<step>"]`]
1757
+ };
1758
+ const limit = Object.hasOwn(takes, command ?? "") ? takes[command] : void 0;
1722
1759
  try {
1723
- if (!command || [
1724
- "help",
1725
- "--help",
1726
- "-h"
1727
- ].includes(command)) console.log(HELP);
1760
+ if (!command || command === "help" || given.some((word) => ["--help", "-h"].includes(word))) console.log(HELP);
1761
+ else if (limit && words.length > limit[0]) throw new Refused([`That's more than it takes: ${limit[1]}`]);
1728
1762
  else {
1729
1763
  await putBack(repo());
1730
1764
  if (command === "start") {
1731
- if (!review) throw needs(`playthrough start <review> "<title>" "<what was asked>"`);
1765
+ if (!review) throw needs(takes.start[1]);
1732
1766
  await start(repo(), review, words[0], words[1]);
1733
1767
  } else if (command === "record") {
1734
- if (!review) throw needs("playthrough record <review>");
1768
+ if (!review) throw needs(takes.record[1]);
1735
1769
  await record(repo(), review);
1736
1770
  } else if (command === "wait") {
1737
- if (!review) throw needs("playthrough wait <review>");
1771
+ if (!review) throw needs(takes.wait[1]);
1738
1772
  await wait(repo(), review);
1739
1773
  } else if (command === "walk") {
1740
- if (!review) throw needs("playthrough walk <review>");
1774
+ if (!review) throw needs(takes.walk[1]);
1741
1775
  await walk(repo(), review);
1742
1776
  } else if (command === "open") await open(repo(), review);
1743
1777
  else if (command === "take-over") {
1744
- if (!review) throw needs(`playthrough take-over <review> "<flow>" ["<step>"]`);
1778
+ if (!review) throw needs(takes["take-over"][1]);
1745
1779
  await takeOver$1(repo(), review, words[0], words[1]);
1746
1780
  } else throw new Refused([`There's no command “${command}”. Run playthrough alone for what each one does.`]);
1747
1781
  }