@vincentt-xr/harness 0.1.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 (40) hide show
  1. package/README.md +87 -0
  2. package/dist/client/HarnessProvider.d.ts +21 -0
  3. package/dist/client/HarnessProvider.js +64 -0
  4. package/dist/client/buffer.d.ts +28 -0
  5. package/dist/client/buffer.js +66 -0
  6. package/dist/client/index.d.ts +3 -0
  7. package/dist/client/index.js +5 -0
  8. package/dist/client/instrument.d.ts +13 -0
  9. package/dist/client/instrument.js +160 -0
  10. package/dist/client/sampler.d.ts +11 -0
  11. package/dist/client/sampler.js +72 -0
  12. package/dist/client/serialize.d.ts +32 -0
  13. package/dist/client/serialize.js +99 -0
  14. package/dist/client/trace.d.ts +31 -0
  15. package/dist/client/trace.js +87 -0
  16. package/dist/mcp/backend.d.ts +52 -0
  17. package/dist/mcp/backend.js +146 -0
  18. package/dist/mcp/cli.d.ts +2 -0
  19. package/dist/mcp/cli.js +10 -0
  20. package/dist/mcp/diagnostics.d.ts +13 -0
  21. package/dist/mcp/diagnostics.js +61 -0
  22. package/dist/mcp/server.d.ts +14 -0
  23. package/dist/mcp/server.js +140 -0
  24. package/dist/preview/cloudflared.d.ts +13 -0
  25. package/dist/preview/cloudflared.js +37 -0
  26. package/dist/preview/index.d.ts +15 -0
  27. package/dist/preview/index.js +30 -0
  28. package/dist/relay/cli.d.ts +2 -0
  29. package/dist/relay/cli.js +7 -0
  30. package/dist/relay/server.d.ts +12 -0
  31. package/dist/relay/server.js +85 -0
  32. package/dist/relay/store.d.ts +13 -0
  33. package/dist/relay/store.js +68 -0
  34. package/dist/scaffold/index.d.ts +19 -0
  35. package/dist/scaffold/index.js +72 -0
  36. package/dist/shared/config.d.ts +33 -0
  37. package/dist/shared/config.js +76 -0
  38. package/dist/shared/events.d.ts +78 -0
  39. package/dist/shared/events.js +6 -0
  40. package/package.json +60 -0
@@ -0,0 +1,31 @@
1
+ import type { TraceEvent } from "../shared/events.js";
2
+ /**
3
+ * Pure accumulator: feed it frame timestamps, long-task durations, and marks;
4
+ * ask it to summarize a window. No timers, no DOM — the sampler drives it.
5
+ */
6
+ export declare class TraceAccumulator {
7
+ private frameTimes;
8
+ private longestTask;
9
+ private longTasks;
10
+ private marks;
11
+ private windowStart;
12
+ /** Record a frame boundary (a rAF callback time, ms). */
13
+ frame(t: number): void;
14
+ /** Record a completed task's duration (ms). Only long ones matter. */
15
+ task(durationMs: number): void;
16
+ /** Record a performance.mark name seen in this window. */
17
+ mark(name: string): void;
18
+ /** Whether enough has accumulated to be worth emitting. */
19
+ hasSamples(): boolean;
20
+ /**
21
+ * Summarize and RESET for the next window. `now` closes the window. seq/t are
22
+ * supplied by the caller (the buffer stamps them).
23
+ */
24
+ summarize(now: number, seq: number): TraceEvent;
25
+ private reset;
26
+ }
27
+ /**
28
+ * Frames-per-second from a list of frame timestamps: (frames - 1) intervals
29
+ * over the elapsed span. Fewer than two frames → 0 (can't measure a rate).
30
+ */
31
+ export declare function computeFps(frameTimes: number[]): number;
@@ -0,0 +1,87 @@
1
+ // The performance-tracing tier. An in-app sampler accumulates cheap perf
2
+ // signals — frame intervals (→ fps), long tasks, and performance.mark names —
3
+ // over a rolling window and emits one TraceEvent per window. This is the tier
4
+ // that replaces hand-exporting a DevTools .json.gz to spot jank.
5
+ //
6
+ // The aggregation is a pure reducer (TraceAccumulator) so it is unit-testable;
7
+ // the imperative sampler (rAF loop + PerformanceObserver) is a thin shell that
8
+ // feeds it and is installed by instrument.ts.
9
+ const LONG_TASK_MS = 50; // the standard "long task" threshold
10
+ /**
11
+ * Pure accumulator: feed it frame timestamps, long-task durations, and marks;
12
+ * ask it to summarize a window. No timers, no DOM — the sampler drives it.
13
+ */
14
+ export class TraceAccumulator {
15
+ frameTimes = [];
16
+ longestTask = 0;
17
+ longTasks = 0;
18
+ marks = [];
19
+ windowStart = null;
20
+ /** Record a frame boundary (a rAF callback time, ms). */
21
+ frame(t) {
22
+ if (this.windowStart === null)
23
+ this.windowStart = t;
24
+ this.frameTimes.push(t);
25
+ }
26
+ /** Record a completed task's duration (ms). Only long ones matter. */
27
+ task(durationMs) {
28
+ if (durationMs >= LONG_TASK_MS) {
29
+ this.longTasks += 1;
30
+ if (durationMs > this.longestTask)
31
+ this.longestTask = durationMs;
32
+ }
33
+ }
34
+ /** Record a performance.mark name seen in this window. */
35
+ mark(name) {
36
+ if (!this.marks.includes(name))
37
+ this.marks.push(name);
38
+ }
39
+ /** Whether enough has accumulated to be worth emitting. */
40
+ hasSamples() {
41
+ return this.frameTimes.length > 1 || this.longTasks > 0 || this.marks.length > 0;
42
+ }
43
+ /**
44
+ * Summarize and RESET for the next window. `now` closes the window. seq/t are
45
+ * supplied by the caller (the buffer stamps them).
46
+ */
47
+ summarize(now, seq) {
48
+ const start = this.windowStart ?? now;
49
+ const windowMs = Math.max(1, Math.round(now - start));
50
+ const fps = computeFps(this.frameTimes);
51
+ const event = {
52
+ kind: "trace",
53
+ windowMs,
54
+ fps: round1(fps),
55
+ longestTaskMs: Math.round(this.longestTask),
56
+ longTaskCount: this.longTasks,
57
+ marks: [...this.marks],
58
+ seq,
59
+ t: now,
60
+ };
61
+ this.reset();
62
+ return event;
63
+ }
64
+ reset() {
65
+ this.frameTimes = [];
66
+ this.longestTask = 0;
67
+ this.longTasks = 0;
68
+ this.marks = [];
69
+ this.windowStart = null;
70
+ }
71
+ }
72
+ /**
73
+ * Frames-per-second from a list of frame timestamps: (frames - 1) intervals
74
+ * over the elapsed span. Fewer than two frames → 0 (can't measure a rate).
75
+ */
76
+ export function computeFps(frameTimes) {
77
+ if (frameTimes.length < 2)
78
+ return 0;
79
+ const span = frameTimes[frameTimes.length - 1] - frameTimes[0];
80
+ if (span <= 0)
81
+ return 0;
82
+ const intervals = frameTimes.length - 1;
83
+ return (intervals * 1000) / span;
84
+ }
85
+ function round1(n) {
86
+ return Math.round(n * 10) / 10;
87
+ }
@@ -0,0 +1,52 @@
1
+ import type { ResolvedConfig } from "../shared/config.js";
2
+ export interface CreatedProject {
3
+ projectId: string;
4
+ slug: string;
5
+ name: string;
6
+ }
7
+ export interface PublishResult {
8
+ version: number;
9
+ /** Immutable versioned URL (<slug>.<apex>/v<n>/). */
10
+ url: string;
11
+ /** Stable live alias (<slug>.<apex>/). */
12
+ liveUrl: string;
13
+ }
14
+ export declare class BackendError extends Error {
15
+ readonly status?: number | undefined;
16
+ constructor(message: string, status?: number | undefined);
17
+ }
18
+ /**
19
+ * Create a backend project (reuses POST /projects). A `slug` pins the permanent
20
+ * <slug>.vincentt.app host; omit it and the platform assigns a catchy one. A
21
+ * taken slug comes back 409, surfaced so the caller can re-ask.
22
+ */
23
+ export declare function createProject(cfg: ResolvedConfig, name: string, slug?: string): Promise<CreatedProject>;
24
+ export interface MintedTunnel {
25
+ tunnelId: string;
26
+ /** The public host the phone opens: <slug>-<token>.<previewApex>. */
27
+ hostname: string;
28
+ /** cloudflared run token — `cloudflared tunnel run --token <this>`. */
29
+ runToken: string;
30
+ /** DNS record id, passed back on reap. */
31
+ dnsRecordId: string;
32
+ }
33
+ /** Mint a per-session dev tunnel for `localPort` (the backend holds CF creds). */
34
+ export declare function mintTunnel(cfg: ResolvedConfig, projectId: string, localPort: number): Promise<MintedTunnel>;
35
+ /** Tear down a session tunnel (DNS route + tunnel). Best-effort; never throws. */
36
+ export declare function reapTunnel(cfg: ResolvedConfig, projectId: string, ref: {
37
+ tunnelId: string;
38
+ dnsRecordId?: string;
39
+ }): Promise<void>;
40
+ /** Recursively list files under `dir`, returning dir-relative POSIX paths. */
41
+ export declare function walkDir(dir: string, base?: string): Promise<string[]>;
42
+ /**
43
+ * Upload a locally-built dist and make it live. Each file rides as a multipart
44
+ * part whose filename is its dist-relative path (the backend reconstructs the
45
+ * tree). Requires an index.html at the dist root.
46
+ */
47
+ export declare function publishUpload(cfg: ResolvedConfig, projectId: string, distDir: string, opts?: {
48
+ note?: string;
49
+ commitSha?: string;
50
+ }): Promise<PublishResult>;
51
+ /** The working tree's current commit, for publish provenance. Undefined if not a repo. */
52
+ export declare function gitHeadSha(cwd: string): Promise<string | undefined>;
@@ -0,0 +1,146 @@
1
+ // The lifecycle verbs' backend calls, isolated from the MCP SDK wiring (server.ts)
2
+ // so they're unit-testable and the transport can change without touching them.
3
+ // Every call is PAT-authed (Bearer). The publish path uploads an already-built
4
+ // dist as-is — the backend never rebuilds (Principle 0).
5
+ import { promises as fs } from "node:fs";
6
+ import path from "node:path";
7
+ import { execFile } from "node:child_process";
8
+ import { promisify } from "node:util";
9
+ const execFileAsync = promisify(execFile);
10
+ export class BackendError extends Error {
11
+ status;
12
+ constructor(message, status) {
13
+ super(message);
14
+ this.status = status;
15
+ this.name = "BackendError";
16
+ }
17
+ }
18
+ async function authFetch(cfg, pathname, init = {}) {
19
+ return fetch(`${cfg.apiUrl}${pathname}`, {
20
+ ...init,
21
+ headers: { ...(init.headers ?? {}), authorization: `Bearer ${cfg.pat}` },
22
+ });
23
+ }
24
+ async function safeText(res) {
25
+ try {
26
+ return (await res.text()).slice(0, 300);
27
+ }
28
+ catch {
29
+ return "";
30
+ }
31
+ }
32
+ /**
33
+ * Create a backend project (reuses POST /projects). A `slug` pins the permanent
34
+ * <slug>.vincentt.app host; omit it and the platform assigns a catchy one. A
35
+ * taken slug comes back 409, surfaced so the caller can re-ask.
36
+ */
37
+ export async function createProject(cfg, name, slug) {
38
+ const res = await authFetch(cfg, "/projects", {
39
+ method: "POST",
40
+ headers: { "content-type": "application/json" },
41
+ body: JSON.stringify({ name, ...(slug ? { slug } : {}) }),
42
+ });
43
+ if (res.status === 409) {
44
+ throw new BackendError(`slug "${slug}" is already in use — pick another.`, 409);
45
+ }
46
+ if (!res.ok) {
47
+ throw new BackendError(`create project failed (${res.status}): ${await safeText(res)}`, res.status);
48
+ }
49
+ const p = (await res.json());
50
+ const projectId = p._id ?? p.id;
51
+ if (!projectId || !p.slug) {
52
+ throw new BackendError("create project: response missing id/slug");
53
+ }
54
+ return { projectId, slug: p.slug, name: p.name ?? name };
55
+ }
56
+ /** Mint a per-session dev tunnel for `localPort` (the backend holds CF creds). */
57
+ export async function mintTunnel(cfg, projectId, localPort) {
58
+ const res = await authFetch(cfg, `/projects/${projectId}/tunnel`, {
59
+ method: "POST",
60
+ headers: { "content-type": "application/json" },
61
+ body: JSON.stringify({ port: localPort }),
62
+ });
63
+ if (!res.ok) {
64
+ throw new BackendError(`tunnel mint failed (${res.status}): ${await safeText(res)}`, res.status);
65
+ }
66
+ return (await res.json());
67
+ }
68
+ /** Tear down a session tunnel (DNS route + tunnel). Best-effort; never throws. */
69
+ export async function reapTunnel(cfg, projectId, ref) {
70
+ try {
71
+ await authFetch(cfg, `/projects/${projectId}/tunnel/reap`, {
72
+ method: "POST",
73
+ headers: { "content-type": "application/json" },
74
+ body: JSON.stringify(ref),
75
+ });
76
+ }
77
+ catch {
78
+ // Reap is a cleanup courtesy — a failure must never crash the preview teardown.
79
+ }
80
+ }
81
+ /** Recursively list files under `dir`, returning dir-relative POSIX paths. */
82
+ export async function walkDir(dir, base = dir) {
83
+ const out = [];
84
+ let entries;
85
+ try {
86
+ entries = await fs.readdir(dir, { withFileTypes: true });
87
+ }
88
+ catch (err) {
89
+ if (err.code === "ENOENT") {
90
+ throw new BackendError(`dist directory not found: ${dir}`);
91
+ }
92
+ throw err;
93
+ }
94
+ for (const entry of entries) {
95
+ const full = path.join(dir, entry.name);
96
+ if (entry.isDirectory())
97
+ out.push(...(await walkDir(full, base)));
98
+ else if (entry.isFile())
99
+ out.push(path.relative(base, full).split(path.sep).join("/"));
100
+ }
101
+ return out;
102
+ }
103
+ /**
104
+ * Upload a locally-built dist and make it live. Each file rides as a multipart
105
+ * part whose filename is its dist-relative path (the backend reconstructs the
106
+ * tree). Requires an index.html at the dist root.
107
+ */
108
+ export async function publishUpload(cfg, projectId, distDir, opts = {}) {
109
+ const files = await walkDir(distDir);
110
+ if (!files.includes("index.html")) {
111
+ throw new BackendError(`dist at ${distDir} has no index.html at its root — did the build run?`);
112
+ }
113
+ const form = new FormData();
114
+ // The real dist-relative paths ride as an ordered manifest — a multipart filename
115
+ // can't carry a directory (FormData strips it to a basename), so the backend maps
116
+ // paths[i] → files[i] to rebuild the tree.
117
+ form.append("paths", JSON.stringify(files));
118
+ for (const rel of files) {
119
+ const bytes = await fs.readFile(path.join(distDir, rel));
120
+ // Uint8Array (Buffer) is a valid BlobPart; the filename is cosmetic (the backend
121
+ // uses the manifest), but pass the basename so server logs read sensibly.
122
+ form.append("files", new Blob([bytes]), path.basename(rel));
123
+ }
124
+ if (opts.note)
125
+ form.append("note", opts.note);
126
+ if (opts.commitSha)
127
+ form.append("commitSha", opts.commitSha);
128
+ const res = await authFetch(cfg, `/projects/${projectId}/publish/upload`, {
129
+ method: "POST",
130
+ body: form,
131
+ });
132
+ if (!res.ok) {
133
+ throw new BackendError(`publish failed (${res.status}): ${await safeText(res)}`, res.status);
134
+ }
135
+ return (await res.json());
136
+ }
137
+ /** The working tree's current commit, for publish provenance. Undefined if not a repo. */
138
+ export async function gitHeadSha(cwd) {
139
+ try {
140
+ const { stdout } = await execFileAsync("git", ["-C", cwd, "rev-parse", "HEAD"]);
141
+ return stdout.trim();
142
+ }
143
+ catch {
144
+ return undefined;
145
+ }
146
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ // `harness-mcp` — the agent-side MCP server. An MCP agent spawns this over
3
+ // stdio. Point it at a non-default relay with --relay-url.
4
+ import { runStdio } from "./server.js";
5
+ const urlArg = process.argv.indexOf("--relay-url");
6
+ const relayUrl = urlArg !== -1 ? process.argv[urlArg + 1] : undefined;
7
+ runStdio({ relayUrl }).catch((err) => {
8
+ console.error("[harness-mcp] fatal:", err);
9
+ process.exit(1);
10
+ });
@@ -0,0 +1,13 @@
1
+ import type { DiagEvent, LogEvent, NetworkEvent, RelayQuery, RelayResult, TraceEvent } from "../shared/events.js";
2
+ export interface RelayClient {
3
+ query(q: RelayQuery): Promise<RelayResult>;
4
+ }
5
+ /** A RelayClient backed by the relay's HTTP /query endpoint. */
6
+ export declare function httpRelayClient(baseUrl: string): RelayClient;
7
+ export declare function formatLog(e: LogEvent, now: number): string;
8
+ export declare function formatNetwork(e: NetworkEvent, now: number): string;
9
+ export declare function formatTrace(e: TraceEvent, now: number): string;
10
+ /** Render a RelayResult for one kind into the text block a tool returns. */
11
+ export declare function renderResult(result: RelayResult, kind: DiagEvent["kind"], now: number): string;
12
+ /** Only-errors convenience filter applied on top of a log query result. */
13
+ export declare function filterErrors(result: RelayResult): RelayResult;
@@ -0,0 +1,61 @@
1
+ // The MCP server's logic, independent of the MCP SDK surface: fetch events from
2
+ // the relay and format them into the compact text an agent reads. Kept separate
3
+ // from the SDK wiring (server.ts) so the formatting is unit-testable and the SDK
4
+ // version can churn without touching this.
5
+ /** A RelayClient backed by the relay's HTTP /query endpoint. */
6
+ export function httpRelayClient(baseUrl) {
7
+ return {
8
+ async query(q) {
9
+ const res = await fetch(`${baseUrl}/query`, {
10
+ method: "POST",
11
+ headers: { "content-type": "application/json" },
12
+ body: JSON.stringify(q),
13
+ });
14
+ if (!res.ok)
15
+ throw new Error(`relay /query returned ${res.status}`);
16
+ return (await res.json());
17
+ },
18
+ };
19
+ }
20
+ /** Human-time from the client timestamp, relative to now, for the agent's read. */
21
+ function ago(t, now) {
22
+ const s = Math.max(0, Math.round((now - t) / 1000));
23
+ return s < 60 ? `${s}s ago` : `${Math.round(s / 60)}m ago`;
24
+ }
25
+ export function formatLog(e, now) {
26
+ const level = e.level.toUpperCase().padEnd(5);
27
+ const origin = e.origin ? ` (${e.origin})` : "";
28
+ return `[${level}] ${e.message}${origin} · ${ago(e.t, now)} #${e.seq}`;
29
+ }
30
+ export function formatNetwork(e, now) {
31
+ const status = e.error ? `ERR ${e.error}` : String(e.status);
32
+ return `${e.method} ${e.url} → ${status} (${e.durationMs}ms) · ${ago(e.t, now)} #${e.seq}`;
33
+ }
34
+ export function formatTrace(e, now) {
35
+ const marks = e.marks.length ? ` marks: ${e.marks.join(", ")}` : "";
36
+ return (`${e.fps.toFixed(0)}fps over ${e.windowMs}ms · longest task ${e.longestTaskMs}ms · ` +
37
+ `${e.longTaskCount} long task(s)${marks} · ${ago(e.t, now)} #${e.seq}`);
38
+ }
39
+ /** Render a RelayResult for one kind into the text block a tool returns. */
40
+ export function renderResult(result, kind, now) {
41
+ const lines = result.events.map((e) => {
42
+ if (e.kind === "log")
43
+ return formatLog(e, now);
44
+ if (e.kind === "network")
45
+ return formatNetwork(e, now);
46
+ return formatTrace(e, now);
47
+ });
48
+ const header = result.sessions.length > 1 ? `sessions: ${result.sessions.join(", ")}\n` : "";
49
+ const cursor = `\n\n(latestSeq ${result.latestSeq} — pass since=${result.latestSeq} to get only newer ${kind} events)`;
50
+ if (lines.length === 0) {
51
+ return `${header}No ${kind} events${result.latestSeq >= 0 ? " matched" : " yet — is the preview app open on the device?"}.${result.latestSeq >= 0 ? cursor : ""}`;
52
+ }
53
+ return `${header}${lines.join("\n")}${cursor}`;
54
+ }
55
+ /** Only-errors convenience filter applied on top of a log query result. */
56
+ export function filterErrors(result) {
57
+ return {
58
+ ...result,
59
+ events: result.events.filter((e) => e.kind === "log" && e.level === "error"),
60
+ };
61
+ }
@@ -0,0 +1,14 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { type RelayClient } from "./diagnostics.js";
3
+ export interface McpOptions {
4
+ /** Base URL of the relay's HTTP endpoint (default matches the relay CLI). */
5
+ relayUrl?: string;
6
+ /** Injected for tests; defaults to an HTTP client against relayUrl. */
7
+ relay?: RelayClient;
8
+ /** Injected clock for deterministic relative-time rendering in tests. */
9
+ now?: () => number;
10
+ /** Project directory for the lifecycle verbs' binding. Defaults to process.cwd(). */
11
+ cwd?: string;
12
+ }
13
+ export declare function createHarnessMcp(opts?: McpOptions): McpServer;
14
+ export declare function runStdio(opts?: McpOptions): Promise<void>;
@@ -0,0 +1,140 @@
1
+ // The agent-agnostic MCP server. Any MCP-capable agent (Claude Code, Codex,
2
+ // Cursor, Cline, …) points its MCP config at `harness-mcp` and gets three tools
3
+ // to PULL diagnostics off the phone, replacing the user hand-ferrying console
4
+ // output and DevTools traces. All formatting lives in diagnostics.ts; this file
5
+ // is only the MCP SDK wiring.
6
+ import path from "node:path";
7
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9
+ import { z } from "zod";
10
+ import { filterErrors, httpRelayClient, renderResult, } from "./diagnostics.js";
11
+ import { createProject, gitHeadSha, publishUpload } from "./backend.js";
12
+ import { loadProjectBinding, resolveConfig, writeProjectBinding } from "../shared/config.js";
13
+ import { needsScaffold, scaffoldFromTemplate } from "../scaffold/index.js";
14
+ /** Wrap a lifecycle handler so a thrown error returns readable MCP error text. */
15
+ async function guard(run) {
16
+ try {
17
+ return { content: [{ type: "text", text: await run() }] };
18
+ }
19
+ catch (err) {
20
+ const msg = err instanceof Error ? err.message : String(err);
21
+ return { content: [{ type: "text", text: msg }], isError: true };
22
+ }
23
+ }
24
+ const sharedInput = {
25
+ sessionId: z
26
+ .string()
27
+ .optional()
28
+ .describe("Filter to one preview session (phone). Omit for all."),
29
+ since: z
30
+ .number()
31
+ .optional()
32
+ .describe("Return only events with seq greater than this. Use the latestSeq from a prior call to poll for new events."),
33
+ limit: z
34
+ .number()
35
+ .optional()
36
+ .describe("Cap the number of events returned (newest kept)."),
37
+ };
38
+ export function createHarnessMcp(opts = {}) {
39
+ const relay = opts.relay ?? httpRelayClient(opts.relayUrl ?? "http://localhost:7331");
40
+ const now = opts.now ?? Date.now;
41
+ const server = new McpServer({ name: "vincentt-harness", version: "0.1.0" });
42
+ server.registerTool("diag_logs", {
43
+ description: "Read console logs the preview app produced on the device. Use `errorsOnly` to see just errors, `since` to poll for new logs. Replaces the user pasting console output.",
44
+ inputSchema: {
45
+ ...sharedInput,
46
+ errorsOnly: z.boolean().optional().describe("Return only error-level logs."),
47
+ },
48
+ }, async ({ sessionId, since, limit, errorsOnly }) => {
49
+ const q = { kind: "log", sessionId, since, limit };
50
+ let result = await relay.query(q);
51
+ if (errorsOnly)
52
+ result = filterErrors(result);
53
+ return { content: [{ type: "text", text: renderResult(result, "log", now()) }] };
54
+ });
55
+ server.registerTool("diag_network", {
56
+ description: "Read fetch/XHR requests the preview app made on the device (method, URL, status, duration). Use to find failed asset loads or slow calls without DevTools.",
57
+ inputSchema: sharedInput,
58
+ }, async ({ sessionId, since, limit }) => {
59
+ const result = await relay.query({ kind: "network", sessionId, since, limit });
60
+ return {
61
+ content: [{ type: "text", text: renderResult(result, "network", now()) }],
62
+ };
63
+ });
64
+ server.registerTool("diag_trace", {
65
+ description: "Read performance samples from the device (fps, longest main-thread task, long-task count, phase marks). Replaces hand-exporting a DevTools trace to spot jank.",
66
+ inputSchema: sharedInput,
67
+ }, async ({ sessionId, since, limit }) => {
68
+ const result = await relay.query({ kind: "trace", sessionId, since, limit });
69
+ return { content: [{ type: "text", text: renderResult(result, "trace", now()) }] };
70
+ });
71
+ // The cwd the agent spawned this server in IS the creator's project directory —
72
+ // where the .vincentt/project.json binding is read and written.
73
+ const projectCwd = opts.cwd ?? process.cwd();
74
+ server.registerTool("project_create", {
75
+ description: "Create a Vincentt project this working directory publishes to. If the directory has no app yet, it scaffolds the v2-template starter into it (like GitHub's Use-this-template); then it writes a local .vincentt/project.json binding and reserves <slug>.vincentt.app. Ask the user for a project name AND a slug before calling; pass what they give. Omit either to accept a default — name = folder name, slug = a platform-assigned catchy one. The slug is the permanent public subdomain (locked once published), so confirm it with the user. Run once per new app; publish with project_publish to go live.",
76
+ inputSchema: {
77
+ name: z
78
+ .string()
79
+ .optional()
80
+ .describe("Display name for the project. Defaults to the working-directory name."),
81
+ slug: z
82
+ .string()
83
+ .optional()
84
+ .describe("Desired subdomain slug (<slug>.vincentt.app), lowercase kebab-case. Omit to let the platform assign one. Permanent once published."),
85
+ scaffold: z
86
+ .boolean()
87
+ .optional()
88
+ .describe("Whether to scaffold the v2-template starter when the directory has no app. Defaults to true; set false to bind an existing/empty directory without cloning."),
89
+ },
90
+ }, async ({ name, slug, scaffold }) => guard(async () => {
91
+ const existing = await loadProjectBinding(projectCwd);
92
+ if (existing) {
93
+ return `This directory is already bound to project ${existing.projectId} (slug ${existing.slug}). Delete .vincentt/project.json to rebind.`;
94
+ }
95
+ // Resolve config first so a missing PAT fails before any clone/side effect.
96
+ const cfg = await resolveConfig(projectCwd);
97
+ let scaffolded = false;
98
+ if (scaffold !== false && (await needsScaffold(projectCwd))) {
99
+ await scaffoldFromTemplate(projectCwd);
100
+ scaffolded = true;
101
+ }
102
+ const created = await createProject(cfg, name ?? path.basename(projectCwd), slug);
103
+ const bindingPath = await writeProjectBinding(projectCwd, {
104
+ projectId: created.projectId,
105
+ slug: created.slug,
106
+ });
107
+ return ((scaffolded ? "Scaffolded the v2-template starter into this directory.\n" : "") +
108
+ `Created "${created.name}" → slug ${created.slug} (id ${created.projectId}).\n` +
109
+ `Binding written to ${bindingPath} (gitignored).\n` +
110
+ `Build locally, then run project_publish to go live at ${created.slug}.vincentt.app.`);
111
+ }));
112
+ server.registerTool("project_publish", {
113
+ description: "Publish the locally-built dist so this project goes live at <slug>.vincentt.app. Build first (e.g. npm run build), then call this — the dist is uploaded as-is and the server never rebuilds. Requires a prior project_create binding.",
114
+ inputSchema: {
115
+ distDir: z
116
+ .string()
117
+ .optional()
118
+ .describe("Path to the built dist directory, relative to the project (default: dist)."),
119
+ note: z.string().optional().describe("Optional release note recorded with the version."),
120
+ },
121
+ }, async ({ distDir, note }) => guard(async () => {
122
+ const binding = await loadProjectBinding(projectCwd);
123
+ if (!binding) {
124
+ return "No project is bound to this directory. Run project_create first.";
125
+ }
126
+ const cfg = await resolveConfig(projectCwd);
127
+ const dist = path.resolve(projectCwd, distDir ?? "dist");
128
+ const commitSha = await gitHeadSha(projectCwd);
129
+ const result = await publishUpload(cfg, binding.projectId, dist, { note, commitSha });
130
+ return `Published v${result.version}. Live at ${result.liveUrl}\nThis version: ${result.url}`;
131
+ }));
132
+ return server;
133
+ }
134
+ export async function runStdio(opts = {}) {
135
+ const server = createHarnessMcp(opts);
136
+ const transport = new StdioServerTransport();
137
+ await server.connect(transport);
138
+ // stdout is the MCP channel — status goes to stderr only.
139
+ console.error("[harness-mcp] connected over stdio");
140
+ }
@@ -0,0 +1,13 @@
1
+ export interface EnsureCloudflaredOptions {
2
+ /** Progress sink for the one-time download (it is ~40MB and takes a few seconds). */
3
+ onLog?: (message: string) => void;
4
+ }
5
+ /**
6
+ * Return a spawnable cloudflared command/path, provisioning one if the host has
7
+ * none. Resolves to `"cloudflared"` when a system install is on PATH, else to the
8
+ * `cloudflared` package's managed binary (already fetched by its postinstall, or
9
+ * downloaded here on demand when install scripts were skipped). Throws only if the
10
+ * download itself fails (offline / blocked egress) — surface that to the user
11
+ * with the manual `brew install cloudflared` fallback.
12
+ */
13
+ export declare function ensureCloudflared(opts?: EnsureCloudflaredOptions): Promise<string>;
@@ -0,0 +1,37 @@
1
+ // Resolve a runnable cloudflared binary so a preview never hard-fails on a
2
+ // missing system install. The tunnel protocol is proprietary QUIC to Cloudflare's
3
+ // edge — there is no pure-Node connector, so *some* cloudflared binary is
4
+ // required. We prefer one already on PATH (respects a user's own install) and
5
+ // otherwise fall back to the copy the `cloudflared` npm package provisions, so a
6
+ // fresh machine that installed only the harness can still preview on a device.
7
+ import { spawnSync } from "node:child_process";
8
+ import { existsSync } from "node:fs";
9
+ import { mkdir } from "node:fs/promises";
10
+ import path from "node:path";
11
+ function isOnPath() {
12
+ // `which`/`where` exits 0 only when the binary resolves on PATH.
13
+ const probe = process.platform === "win32" ? "where" : "which";
14
+ return spawnSync(probe, ["cloudflared"], { stdio: "ignore" }).status === 0;
15
+ }
16
+ /**
17
+ * Return a spawnable cloudflared command/path, provisioning one if the host has
18
+ * none. Resolves to `"cloudflared"` when a system install is on PATH, else to the
19
+ * `cloudflared` package's managed binary (already fetched by its postinstall, or
20
+ * downloaded here on demand when install scripts were skipped). Throws only if the
21
+ * download itself fails (offline / blocked egress) — surface that to the user
22
+ * with the manual `brew install cloudflared` fallback.
23
+ */
24
+ export async function ensureCloudflared(opts = {}) {
25
+ if (isOnPath())
26
+ return "cloudflared";
27
+ const cloudflared = await import("cloudflared");
28
+ if (existsSync(cloudflared.bin))
29
+ return cloudflared.bin;
30
+ // Reached only when the package's postinstall was skipped (e.g. --ignore-scripts).
31
+ const log = opts.onLog ?? (() => undefined);
32
+ log("cloudflared not found — downloading a one-time copy…");
33
+ await mkdir(path.dirname(cloudflared.bin), { recursive: true });
34
+ await cloudflared.install(cloudflared.bin);
35
+ log(`cloudflared ready at ${cloudflared.bin}`);
36
+ return cloudflared.bin;
37
+ }
@@ -0,0 +1,15 @@
1
+ import { type MintedTunnel } from "../mcp/backend.js";
2
+ export { ensureCloudflared, type EnsureCloudflaredOptions } from "./cloudflared.js";
3
+ export interface SessionTunnel extends MintedTunnel {
4
+ /** Public https URL to open on the device. */
5
+ url: string;
6
+ /** Tear the tunnel down (DNS route + tunnel). Idempotent; call on SIGINT. */
7
+ reap: () => Promise<void>;
8
+ }
9
+ /**
10
+ * Mint a per-session dev tunnel for the project bound to `projectCwd`, routing
11
+ * <slug>-<token>.<apex> to the local `localPort`. Returns the URL to open and a
12
+ * `reap()` for teardown. Throws with an actionable message when the directory is
13
+ * unbound (run project_create) or no backend/PAT is configured.
14
+ */
15
+ export declare function startSessionTunnel(projectCwd: string, localPort: number): Promise<SessionTunnel>;
@@ -0,0 +1,30 @@
1
+ // Preview-script glue for the per-session dev tunnel. Resolves the project
2
+ // binding + machine config and mints a named tunnel via the backend (which holds
3
+ // the Cloudflare account creds). The caller (an app's preview.mjs) runs
4
+ // `cloudflared tunnel run --token <runToken>` itself and calls reap() on exit —
5
+ // this module stays free of any child-process / cloudflared coupling.
6
+ import { loadProjectBinding, resolveConfig } from "../shared/config.js";
7
+ import { mintTunnel, reapTunnel } from "../mcp/backend.js";
8
+ export { ensureCloudflared } from "./cloudflared.js";
9
+ /**
10
+ * Mint a per-session dev tunnel for the project bound to `projectCwd`, routing
11
+ * <slug>-<token>.<apex> to the local `localPort`. Returns the URL to open and a
12
+ * `reap()` for teardown. Throws with an actionable message when the directory is
13
+ * unbound (run project_create) or no backend/PAT is configured.
14
+ */
15
+ export async function startSessionTunnel(projectCwd, localPort) {
16
+ const binding = await loadProjectBinding(projectCwd);
17
+ if (!binding) {
18
+ throw new Error("No project bound to this directory — run project_create first.");
19
+ }
20
+ const cfg = await resolveConfig(projectCwd);
21
+ const minted = await mintTunnel(cfg, binding.projectId, localPort);
22
+ return {
23
+ ...minted,
24
+ url: `https://${minted.hostname}`,
25
+ reap: () => reapTunnel(cfg, binding.projectId, {
26
+ tunnelId: minted.tunnelId,
27
+ dnsRecordId: minted.dnsRecordId,
28
+ }),
29
+ };
30
+ }