@shiyuphan/follow-me 0.1.0 → 0.1.1

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.
@@ -1,8 +1,8 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync, } from "node:fs";
3
- import { dirname, join } from "node:path";
2
+ import { existsSync, linkSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
3
+ import { basename, dirname, join } from "node:path";
4
4
  import { isRepoPath, isSha, isUnitId } from "./diff.js";
5
- import { currentBranch, defaultBaseRef, loadDiff, repoRoot, resolveCommit } from "./git.js";
5
+ import { branchExists, currentBranch, defaultBaseRef, loadDiff, objectsDir, QUARANTINE_PREFIX, repoRoot, resolveCommit, } from "./git.js";
6
6
  import { isArray, isCommentId, isFields, isPlanVersion, parsePlanFile, planHash, serializePlanFile, } from "./plan.js";
7
7
  export class BranchMovedError extends Error {
8
8
  name = "BranchMovedError";
@@ -11,6 +11,110 @@ export class BranchMovedError extends Error {
11
11
  export class SessionFileError extends Error {
12
12
  name = "SessionFileError";
13
13
  }
14
+ /** Another live follow-me process holds the session's lock. */
15
+ export class SessionLockedError extends Error {
16
+ name = "SessionLockedError";
17
+ }
18
+ export function isAlive(pid) {
19
+ try {
20
+ process.kill(pid, 0);
21
+ return true;
22
+ }
23
+ catch (error) {
24
+ // EPERM: the process exists but belongs to another user.
25
+ return error instanceof Error && "code" in error && error.code === "EPERM";
26
+ }
27
+ }
28
+ export function readLock(path) {
29
+ const raw = readJson(path);
30
+ if (raw === undefined)
31
+ return { kind: "free" };
32
+ if (!isFields(raw))
33
+ throw new SessionFileError(`${path} is not a lock`);
34
+ const { pid, command } = raw;
35
+ if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) {
36
+ throw new SessionFileError(`${path} is not a lock`);
37
+ }
38
+ if (typeof command !== "string")
39
+ throw new SessionFileError(`${path} is not a lock`);
40
+ return { kind: isAlive(pid) ? "held" : "stale", pid, command };
41
+ }
42
+ const LOCK_TOKEN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
43
+ export function isLockToken(value) {
44
+ return typeof value === "string" && LOCK_TOKEN.test(value);
45
+ }
46
+ function newToken() {
47
+ const token = randomUUID();
48
+ if (!isLockToken(token))
49
+ throw new Error(`randomUUID returned ${token}`);
50
+ return token;
51
+ }
52
+ function lockContent(command, token) {
53
+ return `${JSON.stringify({ pid: process.pid, command, token })}\n`;
54
+ }
55
+ function holding(path, token) {
56
+ return {
57
+ token,
58
+ release: () => {
59
+ const raw = readJson(path);
60
+ if (isFields(raw) && raw.token === token)
61
+ rmSync(path, { force: true });
62
+ },
63
+ };
64
+ }
65
+ function isCode(error, code) {
66
+ return error instanceof Error && "code" in error && error.code === code;
67
+ }
68
+ /**
69
+ * Takes <dir>/lock for this process. The lock is written whole to a temp file and linked into
70
+ * place, which fails like open(wx) when the lock exists, so a reader never sees a half-written
71
+ * lock. A lock whose pid is dead is taken over.
72
+ */
73
+ function acquireLock(dir, branch, command) {
74
+ const path = join(dir, "lock");
75
+ const token = newToken();
76
+ mkdirSync(dir, { recursive: true });
77
+ for (let attempt = 0;; attempt++) {
78
+ const temp = `${path}.${process.pid}.${token}.tmp`;
79
+ writeFileSync(temp, lockContent(command, token));
80
+ try {
81
+ linkSync(temp, path);
82
+ break;
83
+ }
84
+ catch (error) {
85
+ if (!isCode(error, "EEXIST"))
86
+ throw error;
87
+ }
88
+ finally {
89
+ rmSync(temp, { force: true });
90
+ }
91
+ const state = readLock(path);
92
+ if (state.kind === "held" || attempt > 0) {
93
+ const who = state.kind === "free" ? "another process" : `pid ${state.pid}`;
94
+ const what = state.kind === "free" ? "follow-me" : `follow-me ${state.command}`;
95
+ throw new SessionLockedError(`the session for ${branch} is busy: ${who} is running ${what}; wait for it to finish or stop it`);
96
+ }
97
+ rmSync(path, { force: true });
98
+ }
99
+ return holding(path, token);
100
+ }
101
+ /**
102
+ * Takes over a lock another live process holds and is handing over, identified by its token.
103
+ * The holder waits for the handoff to finish, so nobody else can take the lock between the read
104
+ * and the rename; the old holder's release then finds a foreign token and leaves the lock alone.
105
+ */
106
+ function adoptLock(dir, from, command) {
107
+ const path = join(dir, "lock");
108
+ const raw = readJson(path);
109
+ if (!isFields(raw) || raw.token !== from) {
110
+ throw new SessionLockedError(`${path} is not held by the process handing it over`);
111
+ }
112
+ const token = newToken();
113
+ const temp = `${path}.${process.pid}.${token}.tmp`;
114
+ writeFileSync(temp, lockContent(command, token));
115
+ renameSync(temp, path);
116
+ return holding(path, token);
117
+ }
14
118
  export function slugOf(branch) {
15
119
  return branch.replaceAll("/", "-");
16
120
  }
@@ -208,6 +312,9 @@ function makeSession(root, dir, anchor, diff) {
208
312
  writeJson(join(plansDir, `v${version}.json`), serializePlanFile(plan.file));
209
313
  return version;
210
314
  },
315
+ lock: (command) => acquireLock(dir, anchor.branch, command),
316
+ adoptLock: (from, command) => adoptLock(dir, from, command),
317
+ lockState: () => readLock(join(dir, "lock")),
211
318
  applied: () => {
212
319
  const raw = readJson(appliedPath);
213
320
  if (raw === undefined)
@@ -237,7 +344,7 @@ async function locate(repo, branch) {
237
344
  writeFileSync(ignore, "*\n");
238
345
  return { root, branch: name, dir: join(top, slugOf(name)) };
239
346
  }
240
- function readAnchor(dir) {
347
+ function readAnchorIn(dir) {
241
348
  const path = join(dir, "session.json");
242
349
  const raw = readJson(path);
243
350
  if (raw === undefined)
@@ -247,10 +354,19 @@ function readAnchor(dir) {
247
354
  throw new SessionFileError(`${path} is not a session anchor`);
248
355
  return anchor;
249
356
  }
357
+ function readAnchor(where) {
358
+ const anchor = readAnchorIn(where.dir);
359
+ if (anchor === null)
360
+ return null;
361
+ if (anchor.branch !== where.branch) {
362
+ throw new SessionFileError(`${where.dir} holds the session of branch ${anchor.branch}, and ${where.branch} maps to the same directory; rename one of the two branches`);
363
+ }
364
+ return anchor;
365
+ }
250
366
  /** Anchors the session to the branch's current tip, creating it if needed. Plans and reviews stay. */
251
367
  export async function extractSession(repo, opts = {}) {
252
368
  const where = await locate(repo, opts.branch);
253
- const baseRef = opts.base ?? readAnchor(where.dir)?.baseRef ?? (await defaultBaseRef(where.root));
369
+ const baseRef = opts.base ?? readAnchor(where)?.baseRef ?? (await defaultBaseRef(where.root));
254
370
  const diff = await loadDiff(where.root, baseRef, where.branch);
255
371
  const anchor = {
256
372
  schema: "follow-me/session@1",
@@ -266,7 +382,7 @@ export async function extractSession(repo, opts = {}) {
266
382
  /** Opens the session for a branch, creating it on first use. Throws if the branch moved since. */
267
383
  export async function openSession(repo, opts = {}) {
268
384
  const where = await locate(repo, opts.branch);
269
- const anchor = readAnchor(where.dir);
385
+ const anchor = readAnchor(where);
270
386
  if (anchor === null)
271
387
  return extractSession(where.root, { ...opts, branch: where.branch });
272
388
  const tip = await resolveCommit(where.root, where.branch);
@@ -276,3 +392,94 @@ export async function openSession(repo, opts = {}) {
276
392
  const diff = await loadDiff(where.root, anchor.base, anchor.tip);
277
393
  return makeSession(where.root, where.dir, anchor, diff);
278
394
  }
395
+ /**
396
+ * Opens the session stored in a `<root>/.follow-me/<slug>` directory, as the review server does
397
+ * when a CLI registers it. Throws like openSession when the branch moved.
398
+ */
399
+ export async function openSessionAt(dir) {
400
+ const top = dirname(dir);
401
+ if (basename(top) !== ".follow-me")
402
+ throw new SessionFileError(`${dir} is not a session directory`);
403
+ const anchor = readAnchorIn(dir);
404
+ if (anchor === null)
405
+ throw new SessionFileError(`${dir} has no session.json`);
406
+ if (slugOf(anchor.branch) !== basename(dir)) {
407
+ throw new SessionFileError(`${dir} holds the session of branch ${anchor.branch}`);
408
+ }
409
+ return openSession(dirname(top), { branch: anchor.branch });
410
+ }
411
+ /** "<name>.<pid>.<random>.tmp" as written by writeJson, the lock and the review server. */
412
+ function abandonedTemps(dir) {
413
+ if (!existsSync(dir))
414
+ return [];
415
+ return readdirSync(dir, { recursive: true, encoding: "utf8" })
416
+ .filter((name) => {
417
+ const pid = Number(/\.([0-9]+)\.[0-9a-f-]+\.tmp$/.exec(name)?.[1]);
418
+ return Number.isInteger(pid) && pid > 0 && !isAlive(pid);
419
+ })
420
+ .sort()
421
+ .map((name) => join(dir, name));
422
+ }
423
+ /** Review drafts for a version at or below the latest finished review; none can be resumed. */
424
+ function deadReviewDrafts(dir) {
425
+ const reviewsDir = join(dir, "reviews");
426
+ const latest = versions(reviewsDir).at(-1)?.version ?? 0;
427
+ if (!existsSync(reviewsDir))
428
+ return [];
429
+ return readdirSync(reviewsDir)
430
+ .filter((name) => {
431
+ const n = Number(/^v([1-9][0-9]*)\.draft\.json$/.exec(name)?.[1]);
432
+ return Number.isInteger(n) && n <= latest;
433
+ })
434
+ .sort()
435
+ .map((name) => join(reviewsDir, name));
436
+ }
437
+ /**
438
+ * Finds what follow-me left behind in this worktree: sessions whose branch is gone, review drafts
439
+ * no review can resume, locks and temp files of dead processes, and object quarantines of dead
440
+ * applies. With all, the whole .follow-me/ directory. Removes them unless dryRun. Sessions held by
441
+ * a live process are left alone, and all refuses while one exists.
442
+ */
443
+ export async function cleanSessions(repo, opts) {
444
+ const root = await repoRoot(repo);
445
+ const top = join(root, ".follow-me");
446
+ const sessions = existsSync(top)
447
+ ? readdirSync(top, { withFileTypes: true })
448
+ .filter((e) => e.isDirectory())
449
+ .map((e) => join(top, e.name))
450
+ .sort()
451
+ : [];
452
+ const found = [];
453
+ for (const dir of sessions) {
454
+ const lock = readLock(join(dir, "lock"));
455
+ if (lock.kind === "held") {
456
+ if (!opts.all)
457
+ continue;
458
+ throw new SessionLockedError(`${dir} is busy: pid ${lock.pid} is running follow-me ${lock.command}; wait for it to finish or stop it`);
459
+ }
460
+ if (opts.all)
461
+ continue;
462
+ const anchor = readAnchorIn(dir);
463
+ if (anchor !== null && !(await branchExists(root, anchor.branch))) {
464
+ found.push(dir);
465
+ continue;
466
+ }
467
+ if (lock.kind === "stale")
468
+ found.push(join(dir, "lock"));
469
+ found.push(...abandonedTemps(dir), ...deadReviewDrafts(dir));
470
+ }
471
+ if (opts.all && existsSync(top))
472
+ found.push(top);
473
+ const objects = await objectsDir(root);
474
+ for (const name of readdirSync(objects).sort()) {
475
+ if (!name.startsWith(QUARANTINE_PREFIX))
476
+ continue;
477
+ const pid = Number(name.slice(QUARANTINE_PREFIX.length).split("-")[0]);
478
+ if (Number.isInteger(pid) && !isAlive(pid))
479
+ found.push(join(objects, name));
480
+ }
481
+ if (!opts.dryRun)
482
+ for (const path of found)
483
+ rmSync(path, { recursive: true, force: true });
484
+ return found;
485
+ }
package/dist/src/types.js CHANGED
@@ -9,7 +9,8 @@
9
9
  * git.ts the only module that spawns git: load a Diff, write a branch from a ValidPlan.
10
10
  * session.ts the .follow-me/<slug>/ layout: session anchor, plans, reviews, draft.
11
11
  * prompt.ts the planner prompt for an agent, and the Anthropic API planner.
12
- * server.ts the local review server and its JSON API; serves ui/.
12
+ * server.ts the review server: a registry of hosted sessions, their JSON API, and ui/.
13
+ * host.ts the one server per user: its record in the state dir, attach, start, stop.
13
14
  * cli.ts argv -> one call into the modules above.
14
15
  */
15
16
  export {};
package/dist/ui/app.js CHANGED
@@ -13,6 +13,9 @@ import {
13
13
 
14
14
  const $ = (id) => document.getElementById(id);
15
15
 
16
+ /** "/s/<id>": this page's session on the shared server. Every session API sits under it. */
17
+ const sessionPrefix = /^\/s\/[^/]+/.exec(location.pathname)?.[0] ?? "";
18
+
16
19
  /** Builds an element. Text always goes through textContent, never innerHTML. */
17
20
  function el(tag, props = {}, ...children) {
18
21
  const node = document.createElement(tag);
@@ -40,7 +43,7 @@ const openBoxes = new Set();
40
43
  const editing = new Set();
41
44
 
42
45
  async function api(method, path, body) {
43
- const res = await fetch(path, {
46
+ const res = await fetch(`${sessionPrefix}${path}`, {
44
47
  method,
45
48
  headers: { "content-type": "application/json" },
46
49
  body: body === undefined ? undefined : JSON.stringify(body),
@@ -50,6 +53,27 @@ async function api(method, path, body) {
50
53
  return payload;
51
54
  }
52
55
 
56
+ /** Links to every session the server hosts, so one tab can move between workstreams. */
57
+ async function renderSwitcher() {
58
+ const res = await fetch("/api/sessions").catch(() => null);
59
+ if (!res?.ok) return;
60
+ const sessions = await res.json();
61
+ const here = `${sessionPrefix}/`;
62
+ const nav = $("sessions");
63
+ nav.hidden = sessions.length < 2;
64
+ nav.replaceChildren(
65
+ ...sessions.map((s) =>
66
+ el("a", {
67
+ href: s.url,
68
+ title: s.repo,
69
+ "aria-current": s.url === here ? "page" : undefined,
70
+ text: s.mode === "walk" ? `${s.branch} (walk)` : `${s.branch} v${s.version}`,
71
+ }),
72
+ ),
73
+ el("a", { href: "/", text: "All sessions" }),
74
+ );
75
+ }
76
+
53
77
  function report(error) {
54
78
  $("status").textContent = error ? error.message : "";
55
79
  }
@@ -726,6 +750,7 @@ async function endReview() {
726
750
  el("code", { text: "follow-me feedback" }),
727
751
  " or hand the session to your agent.",
728
752
  ),
753
+ el("p", {}, el("a", { href: "/", text: "All sessions" })),
729
754
  );
730
755
  done.hidden = false;
731
756
  }
@@ -751,6 +776,8 @@ document.addEventListener("keydown", (event) => {
751
776
  });
752
777
 
753
778
  $("end").addEventListener("click", () => guarded(endReview));
779
+ window.addEventListener("focus", () => renderSwitcher());
780
+ renderSwitcher();
754
781
 
755
782
  guarded(async () => {
756
783
  state = await api("GET", "/api/state");
@@ -11,6 +11,7 @@
11
11
  <header class="topbar">
12
12
  <strong>follow-me</strong>
13
13
  <span id="summary" class="muted"></span>
14
+ <nav id="sessions" class="sessions" aria-label="Sessions" hidden></nav>
14
15
  <span id="status" class="status" role="status"></span>
15
16
  <button id="view-toggle" type="button" title="Play the branch commit by commit (space)">
16
17
  Play
package/dist/ui/style.css CHANGED
@@ -63,6 +63,41 @@ body {
63
63
  background: var(--panel);
64
64
  }
65
65
 
66
+ .sessions {
67
+ display: flex;
68
+ gap: 8px;
69
+ font-size: 13px;
70
+ }
71
+
72
+ .sessions[hidden] {
73
+ display: none;
74
+ }
75
+
76
+ .sessions a {
77
+ color: var(--accent);
78
+ }
79
+
80
+ .sessions a[aria-current="page"] {
81
+ color: var(--text);
82
+ font-weight: 600;
83
+ text-decoration: none;
84
+ }
85
+
86
+ body.index {
87
+ display: block;
88
+ height: auto;
89
+ padding: 16px 24px;
90
+ }
91
+
92
+ .session-index {
93
+ padding-left: 18px;
94
+ }
95
+
96
+ .session-index a {
97
+ color: var(--accent);
98
+ font-weight: 600;
99
+ }
100
+
66
101
  .status {
67
102
  margin-left: auto;
68
103
  color: var(--del-text);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shiyuphan/follow-me",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Split a branch's final changes into fine, ordered commits that read like the author's thought process, review them hunk by hunk, and hand the review back to a coding agent.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -8,7 +8,7 @@ description: Split the current branch's net change into fine, story-ordered comm
8
8
  follow-me rewrites a branch as small commits ordered so a reader follows your thinking. It never
9
9
  touches your working tree or index. It writes a new branch and keeps state in `.follow-me/<slug>/`.
10
10
 
11
- Run every command from the repo root. If `follow-me` is not on PATH, ask the user how it is installed.
11
+ Run every command from the repo root. If `follow-me` is not on PATH, ask the user how to run it.
12
12
 
13
13
  ## First pass
14
14
 
@@ -23,7 +23,8 @@ Run every command from the repo root. If `follow-me` is not on PATH, ask the use
23
23
  lines are real mistakes.
24
24
  5. `follow-me apply` freezes the plan as the next version and writes the branch.
25
25
  6. Tell the user to run `follow-me review`. It opens a local page where they comment on messages,
26
- positions, hunks and single lines.
26
+ positions, hunks and single lines. If you start it yourself, use `follow-me review --detach`,
27
+ which prints the page URL and returns; one server hosts every session the user has open.
27
28
 
28
29
  ## After a review
29
30
 
@@ -62,14 +63,3 @@ plan reviews only.
62
63
  - Use unit ids exactly as printed. `h_x:0-3,7` takes change lines 0 to 3 and 7 of that hunk.
63
64
  A bare `h_x` takes whatever lines no other commit claimed.
64
65
  - `f_` and `m_` units are whole; they never take line lists.
65
-
66
- ## Install
67
-
68
- Copy or symlink this folder into the repo you want to use it in:
69
-
70
- ```sh
71
- mkdir -p .claude/skills
72
- ln -s /path/to/follow-me/skills/follow-me .claude/skills/follow-me
73
- ```
74
-
75
- For every repo at once, link it into `~/.claude/skills/follow-me` instead.