playthrough 0.4.3 → 0.5.0

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/INSTRUCTIONS.md CHANGED
@@ -38,15 +38,24 @@ THE APP
38
38
  The first time, add playthrough to the app's devDependencies with the app's own package manager, and run
39
39
  npx playwright install chromium: flows import from it, and record in that browser.
40
40
 
41
- playthrough/app.json says how to start the app, and is committed so every review uses it:
41
+ playthrough/app.json says how to start the app, and is committed so every review uses it. Where the command can be
42
+ given a port, say {port} in both, and Playthrough starts this copy of the app on a port of its own each time it records
43
+ or the person takes a flow over, beside the person's own and any other worktree's:
44
+
45
+ { "start": "npm run dev -- --port {port}", "url": "http://localhost:{port}" }
46
+
47
+ Where it can't, as when another part of the app expects it at one address, give the address it always has:
42
48
 
43
49
  { "start": "npm run dev", "url": "http://localhost:3000" }
44
50
 
51
+ There, every round is recorded on whatever serves that url, and Playthrough starts the app only when nothing does: leave
52
+ a server you didn't start running, and let it follow the working copy, as a dev server does. In a worktree, what serves
53
+ it could be another copy, so Playthrough needs the address free, and says so. An app of several servers, one calling
54
+ another at its address, takes the address it always has, or a copy on a port of its own would call the person's.
55
+
45
56
  Flows run several at once. Where a page shows what every run made, as a blog's index lists every post, add
46
- "oneAtATime": true. Every round is recorded on whatever serves that url, and Playthrough starts the app only when nothing
47
- does: leave a server you didn't start running, and let it follow the working copy, as a dev server does. Recording on
48
- Before sets your work aside with git stash for a minute, puts the app as it was in its place, with playthrough/ as it is
49
- now, and puts your work back after, even when Playthrough is stopped.
57
+ "oneAtATime": true. Recording on Before sets your work aside with git stash for a minute, puts the app as it was in its
58
+ place, with playthrough/ as it is now, and puts your work back after, even when Playthrough is stopped.
50
59
 
51
60
  FLOWS
52
61
 
package/README.md CHANGED
@@ -6,6 +6,8 @@ Playthrough records your coding agent's change the way a person would use it, be
6
6
 
7
7
  To see a review before you install anything, play the one at [playthrough.sh](https://playthrough.sh).
8
8
 
9
+ <a href="https://playthrough.sh"><img src="https://raw.githubusercontent.com/itmostlyworks/playthrough/main/docs/viewer-light.png" alt="The viewer: the agent's recording of Send a reminder, paused on your note and the agent's answer, asking whether it fixes it, with Note, Compare and Take over on the bar"></a>
10
+
9
11
  ## Try it
10
12
 
11
13
  Ask your agent for a change in your app, as you would anyway, and add:
@@ -20,9 +22,13 @@ npx playthrough open
20
22
 
21
23
  Reviews stay in `.playthrough/` in your app's repo, recordings and all; nothing is uploaded. Playthrough needs Node 24 or later.
22
24
 
23
- ## On the example
25
+ You can teach your agent Playthrough once, so “Show me this change” is enough from then on:
26
+
27
+ ```bash
28
+ npx skills add itmostlyworks/playthrough
29
+ ```
24
30
 
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`.
31
+ In Claude Code, it's a plugin too: `/plugin install playthrough --marketplace itmostlyworks/playthrough`.
26
32
 
27
33
  ## How a review goes
28
34
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "playthrough",
3
- "version": "0.4.3",
3
+ "version": "0.5.0",
4
4
  "description": "Review what your coding agent built in your web app by watching recordings of it.",
5
5
  "keywords": [
6
6
  "coding-agent",
@@ -1,12 +1,13 @@
1
- import { A as list, b as Refused, g as readingsOf, j as quote, m as glimpsesOf, p as firstLine, x as Reviews } from "./reporter-DE8aueAO.js";
1
+ import { A as quote, b as Reviews, h as readingsOf, k as list, m as glimpsesOf, p as firstLine, y as Refused } from "./reporter-Dr7dWwSf.js";
2
2
  import * as modules from "node:module";
3
3
  import { createRequire } from "node:module";
4
4
  import { existsSync, watch } from "node:fs";
5
- import { spawn } from "node:child_process";
5
+ import { execFileSync, spawn } from "node:child_process";
6
6
  import { mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
7
7
  import { createHash, randomUUID } from "node:crypto";
8
8
  import { constants, tmpdir } from "node:os";
9
- import { dirname, join, relative } from "node:path";
9
+ import { dirname, join, relative, resolve } from "node:path";
10
+ import { createServer } from "node:net";
10
11
  import { chromium, request } from "@playwright/test";
11
12
  import { fileURLToPath, pathToFileURL } from "node:url";
12
13
  // ../review/src/steps.ts
@@ -82,7 +83,7 @@ function folder(root) {
82
83
  },
83
84
  async write(path, bytes) {
84
85
  const file = join(root, path);
85
- const unfinished = `${file}.${process.pid}.${Date.now()}.writing`;
86
+ const unfinished = `${file}.${randomUUID()}.writing`;
86
87
  await mkdir(dirname(file), { recursive: true });
87
88
  await writeFile(unfinished, bytes);
88
89
  await rename(unfinished, file);
@@ -132,7 +133,7 @@ function repo(root = home(process.cwd())) {
132
133
  await writeFile(join(reviews, ".gitignore"), "*\n");
133
134
  },
134
135
  async app() {
135
- const how = `Say how to start the app in playthrough/app.json: { "start": "<the command that serves it>", "url": "<where it's served>" }.`;
136
+ const how = `Say how to start the app in playthrough/app.json: ${PORTED}, or with the address it always has where it can't be given a port.`;
136
137
  const text = await readFile(appFile, "utf8").catch(() => void 0);
137
138
  if (text === void 0) throw new Refused([`Playthrough needs to start the app to record it. ${how}`]);
138
139
  let app;
@@ -141,12 +142,19 @@ function repo(root = home(process.cwd())) {
141
142
  } catch (error) {
142
143
  throw new Refused([`playthrough/app.json isn't JSON: ${error.message}. ${how}`]);
143
144
  }
144
- if (!isText(app.start) || !isText(app.url)) throw new Refused([`playthrough/app.json needs both “start” and “url”. ${how}`]);
145
+ if (!app || typeof app !== "object" || !isText(app.start) || !isText(app.url)) throw new Refused([`playthrough/app.json needs both “start” and “url”. ${how}`]);
146
+ const saying = [app.start, app.url].filter((said) => said.includes("{port}")).length;
147
+ if (saying === 1) throw new Refused([`playthrough/app.json says {port} in only one of “start” and “url”. Say it in both, so the app is started on the port Playthrough gives it and found there: ${PORTED}.`]);
148
+ const port = saying ? await portFor(root) : void 0;
149
+ const given = (said) => port === void 0 ? said : said.replaceAll("{port}", String(port));
150
+ const url = given(app.url);
151
+ if (!/^https?:$/.test(URL.parse(url)?.protocol ?? "")) throw new Refused([`playthrough/app.json's “url” is a whole address, as a browser takes it, like http://localhost:{port}, not “${app.url}”.`]);
145
152
  if (app.oneAtATime !== void 0 && typeof app.oneAtATime !== "boolean") throw new Refused([`playthrough/app.json's “oneAtATime” is true or false: true runs each flow by itself.`]);
146
153
  return {
147
- start: app.start,
148
- url: app.url,
149
- ...app.oneAtATime && { oneAtATime: true }
154
+ start: given(app.start),
155
+ url,
156
+ ...app.oneAtATime && { oneAtATime: true },
157
+ ...port !== void 0 && { port }
150
158
  };
151
159
  }
152
160
  };
@@ -157,6 +165,35 @@ function home(here) {
157
165
  if (existsSync(join(folder, ".git")) || dirname(folder) === folder) return here;
158
166
  }
159
167
  }
168
+ const PORTED = `{ "start": "npm run dev -- --port {port}", "url": "http://localhost:{port}" }`;
169
+ const ports = /* @__PURE__ */ new Map();
170
+ const portFor = (root) => ports.get(root) ?? ports.set(root, free()).get(root);
171
+ const free = () => new Promise((resolve, reject) => {
172
+ const server = createServer();
173
+ server.once("error", reject);
174
+ server.listen(0, "127.0.0.1", () => {
175
+ const { port } = server.address();
176
+ server.close(() => resolve(port));
177
+ });
178
+ });
179
+ function copies(root) {
180
+ try {
181
+ const git = (...args) => execFileSync("git", args, {
182
+ cwd: root,
183
+ encoding: "utf8",
184
+ stdio: [
185
+ "ignore",
186
+ "pipe",
187
+ "ignore"
188
+ ]
189
+ }).trim();
190
+ const inside = relative(git("rev-parse", "--show-toplevel"), root);
191
+ return git("worktree", "list", "--porcelain").split("\n").filter((line) => line.startsWith("worktree ")).map((line) => line.slice(9)).map((worktree) => resolve(worktree, inside)).filter((folder) => existsSync(folder));
192
+ } catch {
193
+ return [resolve(root)];
194
+ }
195
+ }
196
+ const inWorktree = (root) => copies(root)[0] !== resolve(root);
160
197
  const isText = (value) => typeof value === "string" && value.trim() !== "";
161
198
  // src/setup.ts
162
199
  const OURS = [
@@ -250,8 +287,9 @@ async function giveBackOf({ after }, borrowed, whose) {
250
287
  for (const name of left) {
251
288
  const file = join(borrowed, `${process.pid}-${name.slice(name.indexOf("-") + 1)}`);
252
289
  if (!await rename(join(borrowed, name), file).then(() => true, () => false)) continue;
253
- const { baseURL, setup } = JSON.parse(await readFile(file, "utf8"));
254
- await giveBack(after, file, baseURL, setup);
290
+ const given = await readFile(file, "utf8").then(JSON.parse).catch(() => void 0);
291
+ if (!given) await rm(file, { force: true });
292
+ else await giveBack(after, file, given.baseURL, given.setup);
255
293
  }
256
294
  }
257
295
  const giving = /* @__PURE__ */ new Set();
@@ -284,7 +322,7 @@ function alive(pid) {
284
322
  }
285
323
  }
286
324
  // src/app.ts
287
- let started;
325
+ const started = /* @__PURE__ */ new Map();
288
326
  let ending = false;
289
327
  async function keepRunning(app, root) {
290
328
  if (!ending) {
@@ -297,12 +335,17 @@ async function keepRunning(app, root) {
297
335
  "SIGTERM",
298
336
  "SIGHUP"
299
337
  ]) process.once(signal, end);
300
- process.once("exit", stop);
338
+ process.once("exit", () => [...started.keys()].forEach(stop));
339
+ }
340
+ const ours = started.get(app.url);
341
+ if (ours && ours.root !== root) await stopApp(app.url);
342
+ if (await serves(app.url)) {
343
+ if (ours?.root === root || app.port !== void 0 || !inWorktree(root)) return;
344
+ throw new Refused([`Something else answers at ${app.url}, most likely the app running from another folder. This is a worktree, so Playthrough runs this copy of the app there itself: whoever started what's there can stop it, then try again.`, `Or give each copy a port of its own in playthrough/app.json: ${PORTED}.`]);
301
345
  }
302
- if (await serves(app.url)) return;
303
346
  console.log(`Starting the app: ${app.start}`);
304
347
  const began = Date.now();
305
- started = spawn(app.start, {
348
+ const running = spawn(app.start, {
306
349
  cwd: root,
307
350
  shell: true,
308
351
  stdio: [
@@ -312,17 +355,21 @@ async function keepRunning(app, root) {
312
355
  ],
313
356
  detached: true
314
357
  });
358
+ started.set(app.url, {
359
+ app: running,
360
+ root
361
+ });
315
362
  const said = [];
316
- for (const output of [started.stdout, started.stderr]) {
363
+ for (const output of [running.stdout, running.stderr]) {
317
364
  output.on("data", (chunk) => {
318
365
  said.push(...chunk.toString().split("\n").filter((line) => line.trim() && !/^\s+at /.test(line)));
319
366
  said.splice(0, said.length - 10);
320
367
  });
321
368
  output.unref();
322
369
  }
323
- started.unref();
370
+ running.unref();
324
371
  let ended = false;
325
- started.once("exit", () => ended = true);
372
+ running.once("exit", () => ended = true);
326
373
  const saying = () => said.length ? ["It said, last:", ...said.map((line) => ` ${line}`)] : [];
327
374
  while (Date.now() - began < 18e4) {
328
375
  if (await serves(app.url)) return Date.now() - began;
@@ -333,7 +380,7 @@ async function keepRunning(app, root) {
333
380
  ]);
334
381
  await new Promise((resolve) => setTimeout(resolve, 250));
335
382
  }
336
- stop();
383
+ stop(app.url);
337
384
  throw new Refused([
338
385
  `The app didn't answer at ${app.url} within three minutes of ${quote(app.start)}.`,
339
386
  ...saying(),
@@ -341,16 +388,17 @@ async function keepRunning(app, root) {
341
388
  ]);
342
389
  }
343
390
  async function stopApp(url) {
344
- if (!stop()) return;
391
+ if (!stop(url)) return;
345
392
  const began = Date.now();
346
393
  while (Date.now() - began < 1e4 && await serves(url)) await new Promise((resolve) => setTimeout(resolve, 250));
347
394
  }
348
- function stop() {
349
- if (!started) return false;
395
+ function stop(url) {
396
+ const ours = started.get(url);
397
+ if (!ours) return false;
350
398
  try {
351
- process.kill(-started.pid);
399
+ process.kill(-ours.app.pid);
352
400
  } catch {}
353
- started = void 0;
401
+ started.delete(url);
354
402
  return true;
355
403
  }
356
404
  const serves = (url) => fetch(url, { signal: AbortSignal.timeout(2e3) }).then((response) => (response.body?.cancel(), true), (error) => error?.name === "TimeoutError");
@@ -1962,7 +2010,7 @@ async function trySetup(repo, again) {
1962
2010
  const app = await repo.app();
1963
2011
  const written = await writtenNow(repo);
1964
2012
  const last = join(repo.tried, "tried.json");
1965
- if (await readFile(last, "utf8").then((text) => JSON.parse(text).written, () => void 0) === written) return;
2013
+ if (await readFile(last, "utf8").then((text) => JSON.parse(text).written).catch(() => void 0) === written) return;
1966
2014
  console.log("Trying the app's setup by itself first, twice, as every flow will start…");
1967
2015
  const browser = await chromium.launch().catch((error) => {
1968
2016
  const { version } = createRequire(import.meta.url)("@playwright/test/package.json");
@@ -2075,6 +2123,7 @@ async function writtenNow(repo) {
2075
2123
  return createHash("sha256").update(await text(repo.appFile)).update("\0").update(await text(repo.setup)).digest("hex");
2076
2124
  }
2077
2125
  // src/flows.ts
2126
+ const recorder = join(dirname(createRequire(import.meta.url).resolve("@rrweb/record")), "record.umd.min.cjs");
2078
2127
  const WALKED = "walked.json";
2079
2128
  const ALONGSIDE = Symbol.for("playthrough: flows taken over here");
2080
2129
  const REACHES_OUTSIDE = "reaches outside";
@@ -2117,7 +2166,6 @@ async function runFlows(repo, id, round, { before, first = () => false }) {
2117
2166
  const app = await repo.app();
2118
2167
  if (!before) await trySetup(repo, `playthrough record ${id}`);
2119
2168
  const { started, rounds } = await repo.reviews.get(id);
2120
- const recorder = join(dirname(createRequire(import.meta.url).resolve("@rrweb/record")), "record.umd.min.cjs");
2121
2169
  const longest = (rounds.filter((kept) => kept.number < round).at(-1)?.flows ?? []).toSorted((a, b) => b.length - a.length).map((flow) => flow.name);
2122
2170
  const run = (only, results) => runOnce({
2123
2171
  flows,
@@ -2198,7 +2246,7 @@ async function keepFailed(repo, id, failed) {
2198
2246
  await mkdir(folder, { recursive: true });
2199
2247
  await writeFile(join(folder, FAILED), JSON.stringify(failed.map(({ flow }) => flow)));
2200
2248
  }
2201
- const failedLastTime = (repo, id) => readFile(join(repo.failed(id), FAILED), "utf8").then(JSON.parse, () => []);
2249
+ const failedLastTime = (repo, id) => readFile(join(repo.failed(id), FAILED), "utf8").then(JSON.parse).catch(() => []);
2202
2250
  const FAILED = "flows.json";
2203
2251
  const whereItStopped = (repo, id, failed) => `What the page held where ${failed.length > 1 ? "each" : "it"} stopped is in ${repo.show(repo.failed(id))}/.`;
2204
2252
  function inMemory(number, recorded) {
@@ -2249,7 +2297,6 @@ async function runOnce(run) {
2249
2297
  async function walkFork(repo, id, walk) {
2250
2298
  const app = await repo.app();
2251
2299
  const { started } = await repo.reviews.get(id);
2252
- const recorder = join(dirname(createRequire(import.meta.url).resolve("@rrweb/record")), "record.umd.min.cjs");
2253
2300
  const folder = await mkdtemp(join(tmpdir(), "playthrough-"));
2254
2301
  try {
2255
2302
  await playwright({
@@ -2318,4 +2365,4 @@ async function playwright(run) {
2318
2365
  });
2319
2366
  });
2320
2367
  }
2321
- export { firstOf$1 as C, setupIn as D, personIn as E, repo as O, changes as S, make as T, placeIn as _, failedAt as a, serves as b, isFlow as c, waitsFor as d, walkFork as f, compareWithEarlier as g, writtenNow as h, called as i, pausedAt as k, makeFlowsFolder as l, trySetup as m, REACHES_OUTSIDE as n, flowsOf as o, whereItStopped as p, WALKED as r, howToWriteFlows as s, ALONGSIDE as t, runFlows as u, shownAt as v, giveBackLeft as w, stopApp as x, keepRunning as y };
2368
+ export { repo as A, changes as C, personIn as D, make as E, setupIn as O, stopApp as S, giveBackLeft as T, compareWithEarlier as _, failedAt as a, keepRunning as b, isFlow as c, runFlows as d, waitsFor as f, writtenNow as g, trySetup as h, called as i, pausedAt as j, copies as k, makeFlowsFolder as l, whereItStopped as m, REACHES_OUTSIDE as n, flowsOf as o, walkFork as p, WALKED as r, howToWriteFlows as s, ALONGSIDE as t, recorder as u, placeIn as v, firstOf$1 as w, serves as x, shownAt as y };