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 +10 -8
- package/package.json +17 -2
- package/src/playthrough.js +58 -24
- package/viewer/assets/{index-Ds2iot3O.js → index-BkU2wK28.js} +21 -21
- package/viewer/index.html +1 -1
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
|
-
|
|
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
|
|
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.
|
|
4
|
-
"description": "Review
|
|
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": {
|
package/src/playthrough.js
CHANGED
|
@@ -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, {
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
}
|