@vincentt-xr/cli 0.9.0 → 0.11.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.
Files changed (52) hide show
  1. package/README.md +40 -13
  2. package/dist/cli/args.d.ts +12 -0
  3. package/dist/cli/args.js +40 -0
  4. package/dist/cli/index.d.ts +1 -1
  5. package/dist/cli/index.js +308 -95
  6. package/dist/cli/usage-text.d.ts +1 -0
  7. package/dist/cli/usage-text.js +97 -0
  8. package/dist/cli/verb-surface.d.ts +24 -0
  9. package/dist/cli/verb-surface.js +49 -0
  10. package/dist/dx/clusters.d.ts +0 -17
  11. package/dist/dx/clusters.js +50 -1
  12. package/dist/lib/backend.d.ts +11 -0
  13. package/dist/lib/backend.js +23 -0
  14. package/dist/lib/classifyTarget.d.ts +42 -0
  15. package/dist/lib/classifyTarget.js +83 -0
  16. package/dist/lib/feedback.d.ts +46 -5
  17. package/dist/lib/feedback.js +76 -19
  18. package/dist/lib/feedbackCopy.d.ts +70 -0
  19. package/dist/lib/feedbackCopy.js +163 -0
  20. package/dist/lib/initFrom.d.ts +26 -4
  21. package/dist/lib/initFrom.js +65 -41
  22. package/dist/lib/lifecycle.d.ts +28 -3
  23. package/dist/lib/lifecycle.js +99 -7
  24. package/dist/lib/listProjects.d.ts +41 -0
  25. package/dist/lib/listProjects.js +77 -0
  26. package/dist/lib/projects.d.ts +64 -8
  27. package/dist/lib/projects.js +316 -24
  28. package/dist/lib/sourceRefCopy.d.ts +19 -0
  29. package/dist/lib/sourceRefCopy.js +36 -0
  30. package/dist/preview/command.d.ts +11 -0
  31. package/dist/preview/command.js +19 -1
  32. package/dist/preview/copy.d.ts +19 -3
  33. package/dist/preview/copy.js +21 -5
  34. package/dist/preview/runner.d.ts +6 -0
  35. package/dist/preview/runner.js +1 -0
  36. package/dist/preview/stream.d.ts +11 -0
  37. package/dist/preview/stream.js +13 -0
  38. package/dist/relay/annotations.d.ts +28 -4
  39. package/dist/relay/annotations.js +23 -7
  40. package/dist/relay/qa.f12.fixtures.d.ts +154 -0
  41. package/dist/relay/qa.f12.fixtures.js +358 -0
  42. package/dist/relay/server.d.ts +41 -3
  43. package/dist/relay/server.js +90 -32
  44. package/package.json +2 -2
  45. package/dist/library/qa.f5-b32.spec.d.ts +0 -1
  46. package/dist/library/qa.f5-b32.spec.js +0 -202
  47. package/dist/library/qa.f5-cli.spec.d.ts +0 -1
  48. package/dist/library/qa.f5-cli.spec.js +0 -433
  49. package/dist/library/qa.f5-contract.spec.d.ts +0 -1
  50. package/dist/library/qa.f5-contract.spec.js +0 -206
  51. package/dist/relay/injectors.d.ts +0 -29
  52. package/dist/relay/injectors.js +0 -72
@@ -0,0 +1,49 @@
1
+ // The accepted-verb surface, pinned. B-F3-8: a verb may be added or aliased,
2
+ // never renamed or removed — grounding naming these verbs ships into creators'
3
+ // repositories and is never read back, so a rename is unseeable, unwarnable and
4
+ // unfixable. Append only; never edit or delete a line.
5
+ // prettier-ignore
6
+ export const PINNED_VERBS = [
7
+ "version", "login", "logout", "whoami",
8
+ "init", "create", "link", "unlink",
9
+ "publish", "preview",
10
+ "logs", "network", "trace",
11
+ "feedback", "relay", "assets", "dx",
12
+ // Appended: a read-only listing. Nothing above was renamed or removed, which
13
+ // is the whole of what B-F3-8 pins — an addition is always permitted.
14
+ "projects",
15
+ ];
16
+ // The dispatch's verb set, as data. `main()` switches over this, so the two can
17
+ // never disagree: a verb added to the CLI is a key added here, and QA-AHG-01
18
+ // reads it as the converse arm of the pin.
19
+ export const VERB_HANDLERS = {
20
+ version: true,
21
+ login: true,
22
+ logout: true,
23
+ whoami: true,
24
+ init: true,
25
+ create: true,
26
+ link: true,
27
+ unlink: true,
28
+ publish: true,
29
+ preview: true,
30
+ logs: true,
31
+ network: true,
32
+ trace: true,
33
+ feedback: true,
34
+ relay: true,
35
+ assets: true,
36
+ dx: true,
37
+ projects: true,
38
+ };
39
+ // Answers "is this a verb this CLI accepts" without running it. The
40
+ // console-redirect strings are NOT accepted here: they reach their explanation
41
+ // through the dispatch's default branch, and treating them as verbs would put
42
+ // the console's vocabulary inside B-F3-8.
43
+ export function resolveVerb(cmd) {
44
+ if (cmd === undefined)
45
+ return undefined;
46
+ return Object.prototype.hasOwnProperty.call(VERB_HANDLERS, cmd)
47
+ ? cmd
48
+ : undefined;
49
+ }
@@ -1,20 +1,3 @@
1
- /**
2
- * Friction detection over journal entries.
3
- *
4
- * Two cluster types, because the two halves of "DX" fail differently:
5
- *
6
- * - `error` — the caller fought the tool and it pushed back. This is the
7
- * classic retry loop (guessed a flag, got an error, tried again).
8
- * - `repetition` — the caller ran the same thing repeatedly and it WORKED
9
- * every time. No error is ever raised, so an error-keyed detector is blind
10
- * to it, yet it is friction: the caller didn't know the first run had
11
- * already given them what they needed. The reported case that motivated
12
- * this file was exactly that shape — repeated `preview` runs, each one
13
- * succeeding, each one minting a fresh link.
14
- *
15
- * Detection is deliberately loose. A false positive costs a line in a report a
16
- * human is already reading; a miss costs a DX gap nobody ever hears about.
17
- */
18
1
  import type { JournalEntry } from "./journal.js";
19
2
  export type ClusterKind = "error" | "repetition";
20
3
  export interface Cluster {
@@ -1,3 +1,47 @@
1
+ /**
2
+ * Friction detection over journal entries.
3
+ *
4
+ * Two cluster types, because the two halves of "DX" fail differently:
5
+ *
6
+ * - `error` — the caller fought the tool and it pushed back. This is the
7
+ * classic retry loop (guessed a flag, got an error, tried again).
8
+ * - `repetition` — the caller ran the same thing repeatedly and it WORKED
9
+ * every time. No error is ever raised, so an error-keyed detector is blind
10
+ * to it, yet it is friction: the caller didn't know the first run had
11
+ * already given them what they needed. The reported case that motivated
12
+ * this file was exactly that shape — repeated `preview` runs, each one
13
+ * succeeding, each one minting a fresh link.
14
+ *
15
+ * Detection is deliberately loose. A false positive costs a line in a report a
16
+ * human is already reading; a miss costs a DX gap nobody ever hears about.
17
+ */
18
+ import { verbOf } from "./skeleton.js";
19
+ /**
20
+ * Non-zero exits a verb DEFINES as ordinary outcomes, not failures.
21
+ *
22
+ * `feedback --wait` exits 64 when its window elapsed with nothing waiting and 65
23
+ * when the preview ended. Both are facts about the channel, and the whole point
24
+ * of the verb is that it is re-run in a tight loop: a caller re-parks after every
25
+ * 64, hundreds of times a day. Counted as errors, those runs read as a
26
+ * fight-with-the-tool that never happened, and the nudge narrates the intended
27
+ * loop as friction — on stderr, which in this loop is a caller's own input.
28
+ *
29
+ * A verb's neutral exits are ITS OWN. Nothing here generalizes 64/65 across the
30
+ * CLI; another verb adding a non-zero-but-fine exit adds a row.
31
+ */
32
+ const NEUTRAL_EXITS = {
33
+ feedback: new Set([64, 65]),
34
+ };
35
+ /**
36
+ * Verbs whose contract is "run it again". Repeating them is not a caller failing
37
+ * to get what they needed on the first try; it is the documented way to use them.
38
+ */
39
+ const LOOPING_VERBS = new Set(["feedback"]);
40
+ function isFailure(e) {
41
+ if (e.exit === 0)
42
+ return false;
43
+ return !NEUTRAL_EXITS[verbOf(e.skel.split(" "))]?.has(e.exit);
44
+ }
1
45
  /** A repetition run must be at least this long to count as friction. */
2
46
  const REPETITION_MIN = 3;
3
47
  /**
@@ -23,8 +67,12 @@ export function findClusters(entries) {
23
67
  let j = i;
24
68
  while (j < entries.length && entries[j].skel === skel)
25
69
  run.push(entries[j++]);
26
- const errors = run.filter((e) => e.exit !== 0).length;
70
+ const errors = run.filter(isFailure).length;
27
71
  const spanMs = span(run);
72
+ // A verb whose whole contract is "run it again" cannot be detected as
73
+ // friction by repetition — the repetition IS the design. Its error arm still
74
+ // applies: an exit 1 from it is a real fault and clusters normally.
75
+ const loops = LOOPING_VERBS.has(verbOf(skel.split(" ")));
28
76
  // A cluster needs at least two calls. A lone failure is usually the tool
29
77
  // working — a signed-out `whoami`, a typo'd verb — and reporting each one
30
78
  // buries the real findings under noise, which is the failure mode that
@@ -40,6 +88,7 @@ export function findClusters(entries) {
40
88
  });
41
89
  }
42
90
  else if (errors === 0 &&
91
+ !loops &&
43
92
  run.length >= REPETITION_MIN &&
44
93
  spanMs <= REPETITION_WINDOW_MS) {
45
94
  clusters.push({
@@ -43,6 +43,17 @@ export declare class ProjectUnreachableError extends BackendError {
43
43
  * a creator's address is always a name they chose.
44
44
  */
45
45
  export declare function createProject(cfg: ResolvedConfig, orgSlug: string, name: string, slug?: string): Promise<CreatedProject>;
46
+ /**
47
+ * List an org's projects. A READ, and the only new call `vincentt projects`
48
+ * makes — the same path `createProject` POSTs to, which is why the org is a slug
49
+ * here and not a bare id.
50
+ *
51
+ * The wire shape is `{projects: [...]}`, each member the same `projectView`
52
+ * `GET /projects/{id}` returns, so it goes through `parseProjectView` rather
53
+ * than a second reader: two parsers for one shape is how a contract break
54
+ * survives a green suite on one of them.
55
+ */
56
+ export declare function listProjects(cfg: ResolvedConfig, orgSlug: string): Promise<ProjectView[]>;
46
57
  /** Read a project by id. Backs `link`; the org is derived server-side from the row. */
47
58
  export declare function getProject(cfg: ResolvedConfig, projectId: string): Promise<ProjectView>;
48
59
  export interface MintedTunnel {
@@ -95,6 +95,29 @@ export async function createProject(cfg, orgSlug, name, slug) {
95
95
  liveUrl: p.liveUrl,
96
96
  };
97
97
  }
98
+ /**
99
+ * List an org's projects. A READ, and the only new call `vincentt projects`
100
+ * makes — the same path `createProject` POSTs to, which is why the org is a slug
101
+ * here and not a bare id.
102
+ *
103
+ * The wire shape is `{projects: [...]}`, each member the same `projectView`
104
+ * `GET /projects/{id}` returns, so it goes through `parseProjectView` rather
105
+ * than a second reader: two parsers for one shape is how a contract break
106
+ * survives a green suite on one of them.
107
+ */
108
+ export async function listProjects(cfg, orgSlug) {
109
+ const res = await authFetch(cfg, `/orgs/${encodeURIComponent(orgSlug)}/projects`);
110
+ if (!res.ok) {
111
+ const body = await errorBody(res);
112
+ throw new BackendError(res.status === 401
113
+ ? authFailureMessage("list projects failed", res.status, await safeText(res))
114
+ : (body.message ??
115
+ `list projects failed (${res.status}): ${await safeText(res)}`), res.status);
116
+ }
117
+ const raw = (await res.json());
118
+ const rows = Array.isArray(raw.projects) ? raw.projects : [];
119
+ return rows.map((p) => parseProjectView(p, "list projects"));
120
+ }
98
121
  /** Read a project by id. Backs `link`; the org is derived server-side from the row. */
99
122
  export async function getProject(cfg, projectId) {
100
123
  const res = await authFetch(cfg, `/projects/${encodeURIComponent(projectId)}`);
@@ -0,0 +1,42 @@
1
+ export type FolderCase = "vincentt-app" | "empty" | "occupied";
2
+ /** Only the two fields the classifier reads, so a probe is trivial to fake. */
3
+ export interface TargetStat {
4
+ isDirectory(): boolean;
5
+ isSymbolicLink(): boolean;
6
+ }
7
+ export interface ClassifyProbes {
8
+ looksLikeApp?: (dir: string) => Promise<boolean>;
9
+ /** lstat, NEVER stat — see rule 2. */
10
+ lstat?: (dir: string) => Promise<TargetStat>;
11
+ readdir?: (dir: string) => Promise<string[]>;
12
+ }
13
+ /**
14
+ * Which of the three cases the target directory is in.
15
+ *
16
+ * Four rules, each of which a caller depends on:
17
+ *
18
+ * 1. `ENOENT` is the ONLY error that means empty. `EACCES`, `EPERM`, `ENOTDIR`,
19
+ * `ELOOP`, `ENAMETOOLONG` and anything unforeseen all return `occupied` —
20
+ * "I could not establish that this is safe to write into" is a refusal.
21
+ * 2. `lstat`, never `stat`. A symlink is `occupied` whatever it points at;
22
+ * `stat` follows it, which is how a scaffold writes outside the directory
23
+ * the creator named.
24
+ * 3. A non-directory is `occupied`. A regular file at the path is not an empty
25
+ * folder.
26
+ * 4. A `readdir` that throws after a successful `lstat` (a TOCTOU window, or a
27
+ * permission change) is `occupied`. The unknown answer is never the writing
28
+ * answer.
29
+ *
30
+ * `looksLikeApp` runs FIRST and unconditionally: order is the contract, because
31
+ * case 2 is defined by BEING a Vincentt app, not by what else is in the folder.
32
+ *
33
+ * Both IO probes are injected so every arm — including the error arms — is
34
+ * testable with no real filesystem. That matters for more than convenience: a
35
+ * mode-000 directory is a no-op as root, so a test staged on a real filesystem
36
+ * would pass for the wrong reason in CI.
37
+ *
38
+ * NOTE: the classifier answers a question at a MOMENT. The window between this
39
+ * answer and the write is closed by the writer's own guards (a non-recursive
40
+ * mkdir, and a realpath re-check), not here.
41
+ */
42
+ export declare function classifyTarget(dir: string, probes?: ClassifyProbes): Promise<FolderCase>;
@@ -0,0 +1,83 @@
1
+ // The three-way folder classification `vincentt create` acts on, and it FAILS
2
+ // CLOSED.
3
+ //
4
+ // `create` used to be a pure registration verb, so "is this a Vincentt app" was a
5
+ // HINT: a wrong answer cost one advisory line. It now decides whether ~30 template
6
+ // files get written, so a wrong answer is a silent write into a directory the
7
+ // creator cares about. That is the hint-becomes-a-gate transition, and it is why
8
+ // this is a security control with its own file rather than another export from
9
+ // projects.ts.
10
+ //
11
+ // This deliberately does NOT call `isNonEmptyDir` (scaffold/index.ts). That
12
+ // predicate catches EVERY error and returns false, which this design would read as
13
+ // "empty". Four measured mis-answers it gives: a mode-000 directory holding files,
14
+ // a symlink to an empty directory (the scaffold writes THROUGH the link, outside
15
+ // the parent the creator named), a dangling symlink, and a regular file at the
16
+ // path. Under `init` a false-empty reaches a confirm prompt a human sees; here it
17
+ // reaches a silent write. `isNonEmptyDir` is left exactly as it is and keeps
18
+ // serving `init`, where its error contract is correct: two predicates, two
19
+ // contracts, each stated in terms of what its caller does with the answer.
20
+ //
21
+ // `needsScaffold` is not used either: it answers only "has a package.json", so a
22
+ // stranger's non-Vincentt Node app lands in its scaffoldable arm.
23
+ import { promises as fs } from "node:fs";
24
+ import { looksLikeVincenttApp } from "./projects.js";
25
+ /**
26
+ * Which of the three cases the target directory is in.
27
+ *
28
+ * Four rules, each of which a caller depends on:
29
+ *
30
+ * 1. `ENOENT` is the ONLY error that means empty. `EACCES`, `EPERM`, `ENOTDIR`,
31
+ * `ELOOP`, `ENAMETOOLONG` and anything unforeseen all return `occupied` —
32
+ * "I could not establish that this is safe to write into" is a refusal.
33
+ * 2. `lstat`, never `stat`. A symlink is `occupied` whatever it points at;
34
+ * `stat` follows it, which is how a scaffold writes outside the directory
35
+ * the creator named.
36
+ * 3. A non-directory is `occupied`. A regular file at the path is not an empty
37
+ * folder.
38
+ * 4. A `readdir` that throws after a successful `lstat` (a TOCTOU window, or a
39
+ * permission change) is `occupied`. The unknown answer is never the writing
40
+ * answer.
41
+ *
42
+ * `looksLikeApp` runs FIRST and unconditionally: order is the contract, because
43
+ * case 2 is defined by BEING a Vincentt app, not by what else is in the folder.
44
+ *
45
+ * Both IO probes are injected so every arm — including the error arms — is
46
+ * testable with no real filesystem. That matters for more than convenience: a
47
+ * mode-000 directory is a no-op as root, so a test staged on a real filesystem
48
+ * would pass for the wrong reason in CI.
49
+ *
50
+ * NOTE: the classifier answers a question at a MOMENT. The window between this
51
+ * answer and the write is closed by the writer's own guards (a non-recursive
52
+ * mkdir, and a realpath re-check), not here.
53
+ */
54
+ export async function classifyTarget(dir, probes = {}) {
55
+ const looksLikeApp = probes.looksLikeApp ?? looksLikeVincenttApp;
56
+ const lstat = probes.lstat ?? ((d) => fs.lstat(d));
57
+ const readdir = probes.readdir ?? ((d) => fs.readdir(d));
58
+ if (await looksLikeApp(dir))
59
+ return "vincentt-app";
60
+ try {
61
+ const st = await lstat(dir);
62
+ if (st.isSymbolicLink())
63
+ return "occupied";
64
+ if (!st.isDirectory())
65
+ return "occupied";
66
+ }
67
+ catch (err) {
68
+ // Read defensively rather than through a helper. `err.code` on a thrown
69
+ // string or null THROWS, and this catch sits inside a security control: an
70
+ // exception escaping here would propagate as an unhandled failure of the
71
+ // whole verb rather than as the refusal the contract promises.
72
+ const code = err?.code;
73
+ if (code === "ENOENT")
74
+ return "empty"; // `create <name>` creates it
75
+ return "occupied";
76
+ }
77
+ try {
78
+ return (await readdir(dir)).length === 0 ? "empty" : "occupied";
79
+ }
80
+ catch {
81
+ return "occupied";
82
+ }
83
+ }
@@ -2,14 +2,55 @@ import type { Annotation } from "@vincentt-xr/harness/events";
2
2
  export declare function cursorPath(cwd: string): string;
3
3
  /** Last-consumed annotation seq for this project (0 if none consumed yet). */
4
4
  export declare function readCursor(cwd: string): Promise<number>;
5
- /** Advance the cursor. Called ONLY after the annotation was handed off. */
5
+ /**
6
+ * Advance the cursor. Called ONLY after the annotation was handed off.
7
+ *
8
+ * Temp file + same-directory rename, because `fs.writeFile` is not atomic and a
9
+ * kill mid-write is reachable on exactly the kill-mid-drain path this channel is
10
+ * designed around. A torn file reads as 0 under the strict parser, so the
11
+ * failure degrades to re-delivering the whole run — safe, but noisy and
12
+ * avoidable. Same directory keeps the rename on one filesystem, which is what
13
+ * makes it atomic.
14
+ */
6
15
  export declare function writeCursor(cwd: string, seq: number): Promise<void>;
7
16
  /**
8
- * Long-poll the relay for the next annotation after `after`. Resolves the
9
- * annotation, or null when the poll window elapsed with nothing new. Throws a
10
- * clear hint when no preview relay is running for this project.
17
+ * The three outcomes of a poll. `Annotation | null` could only express two, and
18
+ * a channel that has ENDED is not a channel that is merely quiet: a caller
19
+ * re-parks on `empty` and must not on `closed`.
20
+ */
21
+ export type NextResult = {
22
+ kind: "annotation";
23
+ annotation: Annotation;
24
+ }
25
+ /** The window elapsed with nothing pending — re-parking is correct and free. */
26
+ | {
27
+ kind: "empty";
28
+ }
29
+ /** The preview session has ended; nothing more can arrive on this channel. */
30
+ | {
31
+ kind: "closed";
32
+ reason: ClosedReason;
33
+ };
34
+ /**
35
+ * Which of the two session-end routes fired. They are indistinguishable to the
36
+ * creator and print identically by default; the difference is triage detail
37
+ * surfaced under `--verbose`.
38
+ */
39
+ export type ClosedReason =
40
+ /** The relay answered a parked long-poll 410 as it tore down. */
41
+ "signalled"
42
+ /** No relay is answering for this tree — a kill, a reboot, a stale marker. */
43
+ | "unreachable";
44
+ /**
45
+ * Long-poll the relay for the next annotation after `after`.
46
+ *
47
+ * A missing marker and an unreachable relay both resolve `closed` rather than
48
+ * throwing: "no preview is running for this tree" is the same fact the graceful
49
+ * 410 carries, and the caller's correct response to both is identical. Throwing
50
+ * would reach the top-level catch and exit 1 — a fault code for an ordinary,
51
+ * expected end.
11
52
  */
12
53
  export declare function fetchNext(cwd: string, opts: {
13
54
  after: number;
14
55
  timeoutMs: number;
15
- }): Promise<Annotation | null>;
56
+ }): Promise<NextResult>;
@@ -1,12 +1,29 @@
1
- // The agent-agnostic reverse-channel consumer behind `vincentt feedback --wait`.
2
- // Long-polls the running preview's relay for the next annotation after a locally
3
- // persisted cursor. Delivery is at-least-once: the caller advances the cursor only
4
- // after it has successfully handed the annotation off (printed it), so a crash
5
- // before that re-delivers on the next call.
1
+ // The host-neutral reverse-channel consumer behind `vincentt feedback`. Long-polls
2
+ // the running preview's relay for the next annotation after a locally persisted
3
+ // cursor.
4
+ //
5
+ // Delivery is at-least-once, and every rule in this file exists to keep the error
6
+ // on that side: the caller advances the cursor only after the annotation's write
7
+ // has COMPLETED (see the drain in cli/index.ts), the cursor parser can only ever
8
+ // read LOW, and the cursor write is atomic. A cursor that lags re-delivers; a
9
+ // cursor that runs ahead skips silently, which is the one failure the channel
10
+ // forbids.
6
11
  import { promises as fs } from "node:fs";
7
12
  import path from "node:path";
8
- import { readRelayInfo, relayInfoDir } from "./relayInfo.js";
13
+ import { isProcessAlive, isRelayResponding, readRelayInfo, relayInfoDir, } from "./relayInfo.js";
9
14
  const CURSOR_FILE = ".vincentt/annotations/offset";
15
+ /**
16
+ * A cursor that reads too LOW re-delivers, which is the accepted cost of
17
+ * at-least-once. A cursor that reads too HIGH silently SKIPS, which is the
18
+ * failure this channel forbids. So the parser is allowed to err only in one
19
+ * direction: anything that is not a bare non-negative integer is 0.
20
+ *
21
+ * `Number(raw.trim()) || 0` erred the forbidden way — `1e3` is 1000 (a thousand
22
+ * annotations skipped with no error anywhere), `-5` is -5, `0x10` is 16. And §9
23
+ * invites the creator to inspect and repair this file by hand, so a hand-edited
24
+ * value is foreseeable input rather than a hypothetical one.
25
+ */
26
+ const CURSOR_RE = /^\d+$/;
10
27
  export function cursorPath(cwd) {
11
28
  return path.join(cwd, CURSOR_FILE);
12
29
  }
@@ -23,7 +40,12 @@ async function treeCursorPath(cwd) {
23
40
  export async function readCursor(cwd) {
24
41
  try {
25
42
  const raw = await fs.readFile(await treeCursorPath(cwd), "utf8");
26
- return Number(raw.trim()) || 0;
43
+ const trimmed = raw.trim();
44
+ if (!CURSOR_RE.test(trimmed))
45
+ return 0;
46
+ const n = Number(trimmed);
47
+ // Beyond MAX_SAFE_INTEGER, Number() rounds — and rounding UP would skip.
48
+ return Number.isSafeInteger(n) ? n : 0;
27
49
  }
28
50
  catch (err) {
29
51
  if (err.code === "ENOENT")
@@ -31,22 +53,42 @@ export async function readCursor(cwd) {
31
53
  throw err;
32
54
  }
33
55
  }
34
- /** Advance the cursor. Called ONLY after the annotation was handed off. */
56
+ /**
57
+ * Advance the cursor. Called ONLY after the annotation was handed off.
58
+ *
59
+ * Temp file + same-directory rename, because `fs.writeFile` is not atomic and a
60
+ * kill mid-write is reachable on exactly the kill-mid-drain path this channel is
61
+ * designed around. A torn file reads as 0 under the strict parser, so the
62
+ * failure degrades to re-delivering the whole run — safe, but noisy and
63
+ * avoidable. Same directory keeps the rename on one filesystem, which is what
64
+ * makes it atomic.
65
+ */
35
66
  export async function writeCursor(cwd, seq) {
36
67
  const p = await treeCursorPath(cwd);
37
68
  await fs.mkdir(path.dirname(p), { recursive: true });
38
- await fs.writeFile(p, `${seq}\n`, "utf8");
69
+ const tmp = `${p}.${process.pid}.tmp`;
70
+ await fs.writeFile(tmp, `${seq}\n`, "utf8");
71
+ try {
72
+ await fs.rename(tmp, p);
73
+ }
74
+ catch (err) {
75
+ await fs.rm(tmp, { force: true });
76
+ throw err;
77
+ }
39
78
  }
40
79
  /**
41
- * Long-poll the relay for the next annotation after `after`. Resolves the
42
- * annotation, or null when the poll window elapsed with nothing new. Throws a
43
- * clear hint when no preview relay is running for this project.
80
+ * Long-poll the relay for the next annotation after `after`.
81
+ *
82
+ * A missing marker and an unreachable relay both resolve `closed` rather than
83
+ * throwing: "no preview is running for this tree" is the same fact the graceful
84
+ * 410 carries, and the caller's correct response to both is identical. Throwing
85
+ * would reach the top-level catch and exit 1 — a fault code for an ordinary,
86
+ * expected end.
44
87
  */
45
88
  export async function fetchNext(cwd, opts) {
46
89
  const info = await readRelayInfo(cwd);
47
- if (!info) {
48
- throw new Error("No running preview for this project — run `vincentt preview` first.");
49
- }
90
+ if (!info)
91
+ return { kind: "closed", reason: "unreachable" };
50
92
  const base = `http://localhost:${info.relayPort}`;
51
93
  const u = new URL(`${info.harnessPath}/feedback/next`, base);
52
94
  u.searchParams.set("project", cwd);
@@ -56,12 +98,27 @@ export async function fetchNext(cwd, opts) {
56
98
  try {
57
99
  res = await fetch(u);
58
100
  }
59
- catch (err) {
60
- throw new Error(`Could not reach the preview relay on :${info.relayPort} (${err.message}). Is the preview still running?`);
101
+ catch {
102
+ // The socket died under us. The pid gate + relay-identity probe is what
103
+ // separates "the preview ended" from "the request failed but a relay is
104
+ // still there" — only the latter is a fault worth an exit 1.
105
+ if (await relayGone(info.pid, info.relayPort)) {
106
+ return { kind: "closed", reason: "unreachable" };
107
+ }
108
+ throw new Error(`Could not reach the preview relay on :${info.relayPort}. The relay is answering, so this is not a session end.`);
61
109
  }
62
110
  if (res.status === 204)
63
- return null;
111
+ return { kind: "empty" };
112
+ // BEFORE the not-ok throw: 410 is not ok, and a graceful session end must not
113
+ // become a fault.
114
+ if (res.status === 410)
115
+ return { kind: "closed", reason: "signalled" };
64
116
  if (!res.ok)
65
117
  throw new Error(`relay feedback/next returned ${res.status}`);
66
- return (await res.json());
118
+ return { kind: "annotation", annotation: (await res.json()) };
119
+ }
120
+ async function relayGone(pid, port) {
121
+ if (pid !== undefined && !isProcessAlive(pid))
122
+ return true;
123
+ return !(await isRelayResponding(port));
67
124
  }
@@ -0,0 +1,70 @@
1
+ /** Flow C1 — the two lines before the park. */
2
+ export declare function waiting(): string[];
3
+ /** Flow C6 — the opening line of a watch. */
4
+ export declare function watching(): string[];
5
+ /**
6
+ * Flow C2 — the ending of a drain. A count and nothing else: a fact about what
7
+ * this process did, naming no next step.
8
+ *
9
+ * It exists because a human running this in their own terminal otherwise reads
10
+ * three dense JSON lines and cannot tell whether the command is finished. It is
11
+ * deliberately NOT one line per annotation, which would duplicate the preview
12
+ * stream's arrival line in a second place and triple the stderr noise of the
13
+ * exact case this feature exists for.
14
+ */
15
+ export declare function handedOver(n: number): string[];
16
+ /**
17
+ * Flow C3 — the bounded window elapsed. One line, one integer, no glyph.
18
+ *
19
+ * `The preview is still live` is the load-bearing half: it is the fact that
20
+ * makes exit 64 actionable without an instruction. The window is named with its
21
+ * number because an unexplained stop is worse than the calibration.
22
+ */
23
+ export declare function windowElapsed(timeoutMs: number): string[];
24
+ /**
25
+ * Flow C4 — the session ended. `✗`, because this one stops: 65 is not a fault,
26
+ * but it is the end of a channel, and the glyph separates it from 64 at a glance
27
+ * in scrollback.
28
+ *
29
+ * The last line answers the creator's actual fear ("did my annotations die with
30
+ * the preview?") before they can ask it. It does NOT say to start a preview —
31
+ * that precondition is stated in `--help`, which is where a recipe reads it.
32
+ *
33
+ * `0 already handed over` reads slightly oddly and is the price of one sentence
34
+ * covering both counts; suppressing the line at zero would make the ending's
35
+ * shape depend on the count and give a reader two endings to learn.
36
+ */
37
+ export declare function sessionEnded(handed: number): string[];
38
+ /**
39
+ * The triage detail behind a 65, one flag away.
40
+ *
41
+ * The two paths print IDENTICALLY by default: a polling host reaches 65 on every
42
+ * re-park once a preview has ended, and a creator cannot act on the difference
43
+ * between a graceful teardown and a killed process. The distinction belongs to
44
+ * whoever is diagnosing, not to the hot path.
45
+ */
46
+ export declare function sessionEndedDetail(reason: "signalled" | "unreachable", tree: string, cursor: number): string[];
47
+ /** Flow C6 — a human stopped a watch. The preview is not what ended. */
48
+ export declare function stoppedWatching(): string[];
49
+ /** Flow C5 — the usage error. */
50
+ export declare const USAGE = "Usage: vincentt feedback --wait | --watch [--timeout <seconds>]";
51
+ /**
52
+ * Flow C6's human render, on stderr, while stdout keeps the exact NDJSON. One
53
+ * observation, two fidelities — the rule that already governs the perf stream.
54
+ *
55
+ * Columns are the preview stream's columns (time, then the tester, then the
56
+ * content), so a creator who has read a preview stream reads this with nothing
57
+ * new to learn. The screenshot path is its own line, unadorned, so it is
58
+ * copy-clean. NOT rendered: id, seq, projectDir — a human can act on none of
59
+ * them.
60
+ */
61
+ export interface WatchEntry {
62
+ createdAt: number;
63
+ tester?: string;
64
+ message: string;
65
+ screenshotPath: string;
66
+ }
67
+ export declare function watchEntry(e: WatchEntry, columns: number | undefined): string[];
68
+ export declare function stripControl(s: string): string;
69
+ /** Hard wrap at `width`, breaking on spaces where one is available. */
70
+ export declare function wrap(s: string, width: number): string[];