@pi-harness/pi-harness 0.1.84 → 0.1.86

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,6 +1,6 @@
1
1
  import { execFile, type ExecFileException } from "node:child_process";
2
2
  import { existsSync, writeFileSync } from "node:fs";
3
- import { mkdir, mkdtemp, readFile, realpath, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
3
+ import { mkdir, mkdtemp, opendir, readFile, realpath, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
4
4
  import type { IncomingMessage, ServerResponse } from "node:http";
5
5
  import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep } from "node:path";
6
6
  import { tmpdir } from "node:os";
@@ -60,6 +60,12 @@ const PROCESS_FAILURE_TEXT_LIMIT = 400;
60
60
  const PROCESS_TIMEOUT_MS = 120_000;
61
61
  // Mutating commands run user hooks (pre-commit, lint-staged, test suites) and may stage large trees; killing them part-way leaves index.lock and a half-finished operation behind, so they get a far more generous bound than read-only queries.
62
62
  const GIT_MUTATION_TIMEOUT_MS = 120_000;
63
+ const MAX_WORKSPACE_FILES = 5_000;
64
+ const MAX_WORKSPACE_FILE_CANDIDATES = 20_000;
65
+ const MAX_WORKSPACE_DIRECTORIES = 2_000;
66
+ const MAX_WORKSPACE_DEPTH = 16;
67
+ const WORKSPACE_FILE_CACHE_MS = 1_000;
68
+ const IGNORED_WORKSPACE_DIRECTORIES = new Set([".git", "node_modules", ".pi", "dist", "build"]);
63
69
 
64
70
  class PayloadTooLargeError extends Error {
65
71
  constructor() {
@@ -768,6 +774,104 @@ function gitCommand(cwd: string, args: readonly string[], timeoutMs = GIT_TIMEOU
768
774
  });
769
775
  }
770
776
 
777
+ interface WorkspaceFileCatalogue {
778
+ readonly paths: readonly string[];
779
+ readonly truncated: boolean;
780
+ }
781
+
782
+ function ignoredWorkspaceDirectory(name: string): boolean {
783
+ return IGNORED_WORKSPACE_DIRECTORIES.has(process.platform === "win32" || process.platform === "darwin" ? name.toLowerCase() : name);
784
+ }
785
+
786
+ async function existingWorkspaceFiles(root: string, candidates: readonly string[], truncated: boolean): Promise<WorkspaceFileCatalogue> {
787
+ const canonicalRoot = await realpath(resolve(root));
788
+ const paths: string[] = [];
789
+ for (let start = 0; start < candidates.length && paths.length < MAX_WORKSPACE_FILES; start += 64) {
790
+ const batch = candidates.slice(start, start + 64);
791
+ const existing = await Promise.all(
792
+ batch.map(async (path) => {
793
+ const target = resolve(canonicalRoot, path);
794
+ if (escapesRoot(relative(canonicalRoot, target))) return undefined;
795
+ try {
796
+ const canonical = await realpath(target);
797
+ if (escapesRoot(relative(canonicalRoot, canonical)) || !(await stat(canonical)).isFile()) return undefined;
798
+ return path;
799
+ } catch {
800
+ return undefined;
801
+ }
802
+ }),
803
+ );
804
+ for (const path of existing) {
805
+ if (path !== undefined) paths.push(path);
806
+ if (paths.length >= MAX_WORKSPACE_FILES) break;
807
+ }
808
+ }
809
+ return { paths: paths.sort(), truncated: truncated || paths.length >= MAX_WORKSPACE_FILES };
810
+ }
811
+
812
+ async function gitWorkspaceFiles(root: string): Promise<WorkspaceFileCatalogue | undefined> {
813
+ const result = await gitCommand(root, ["ls-files", "--cached", "--others", "--exclude-standard", "--deduplicate", "-z"]);
814
+ if (result.code !== 0 && result.terminated !== "output-limit") {
815
+ if (/not a git repository/iu.test(result.stderr)) return undefined;
816
+ throw new Error(result.terminated ? gitTerminationMessage("ls-files", result.terminated) : result.stderr.trim() || "Unable to list workspace files");
817
+ }
818
+ const lastBoundary = result.stdout.lastIndexOf("\0");
819
+ const completeOutput = result.terminated === "output-limit" ? result.stdout.slice(0, lastBoundary + 1) : result.stdout;
820
+ const allCandidates = [...new Set(completeOutput.split("\0").filter((path) => path !== "" && !path.includes("\uFFFD")))];
821
+ const candidates = allCandidates.slice(0, MAX_WORKSPACE_FILE_CANDIDATES);
822
+ return existingWorkspaceFiles(root, candidates, result.terminated === "output-limit" || allCandidates.length > candidates.length);
823
+ }
824
+
825
+ async function walkedWorkspaceFiles(root: string): Promise<WorkspaceFileCatalogue> {
826
+ const canonicalRoot = await realpath(resolve(root));
827
+ const pending: Array<{ readonly directory: string; readonly depth: number }> = [{ directory: canonicalRoot, depth: 0 }];
828
+ const paths: string[] = [];
829
+ let directories = 0;
830
+ let scanned = 0;
831
+ let truncated = false;
832
+ while (pending.length > 0 && paths.length < MAX_WORKSPACE_FILES && directories < MAX_WORKSPACE_DIRECTORIES && scanned < MAX_WORKSPACE_FILE_CANDIDATES) {
833
+ const current = pending.shift();
834
+ if (current === undefined) break;
835
+ directories += 1;
836
+ try {
837
+ const handle = await opendir(current.directory);
838
+ for await (const entry of handle) {
839
+ scanned += 1;
840
+ if (scanned > MAX_WORKSPACE_FILE_CANDIDATES) {
841
+ truncated = true;
842
+ break;
843
+ }
844
+ const name = entry.name;
845
+ if (name.includes("\uFFFD")) {
846
+ truncated = true;
847
+ continue;
848
+ }
849
+ const target = join(current.directory, name);
850
+ if (entry.isSymbolicLink()) continue;
851
+ if (entry.isFile()) {
852
+ paths.push(relative(canonicalRoot, target).split(sep).join("/"));
853
+ if (paths.length >= MAX_WORKSPACE_FILES) {
854
+ truncated = true;
855
+ break;
856
+ }
857
+ } else if (entry.isDirectory() && !ignoredWorkspaceDirectory(name)) {
858
+ if (current.depth >= MAX_WORKSPACE_DEPTH) truncated = true;
859
+ else pending.push({ directory: target, depth: current.depth + 1 });
860
+ }
861
+ }
862
+ } catch (error) {
863
+ if (current.directory === canonicalRoot) throw error;
864
+ truncated = true;
865
+ }
866
+ }
867
+ if (pending.length > 0 || directories >= MAX_WORKSPACE_DIRECTORIES || scanned >= MAX_WORKSPACE_FILE_CANDIDATES) truncated = true;
868
+ return { paths: paths.sort(), truncated };
869
+ }
870
+
871
+ async function workspaceFiles(root: string): Promise<WorkspaceFileCatalogue> {
872
+ return (await gitWorkspaceFiles(root)) ?? walkedWorkspaceFiles(root);
873
+ }
874
+
771
875
  interface EveryApiCliAuthStatus {
772
876
  configured: false;
773
877
  source: "everyapi-cli";
@@ -880,6 +984,22 @@ export default {
880
984
  };
881
985
  const disposePluginPanels = registerPluginPanels(context, services);
882
986
  let busy = false;
987
+ let workspaceFileCache: { readonly cwd: string; readonly expiresAt: number; readonly catalogue: WorkspaceFileCatalogue } | undefined;
988
+ let workspaceFileRequest: { readonly cwd: string; readonly promise: Promise<WorkspaceFileCatalogue> } | undefined;
989
+ const readWorkspaceFiles = (cwd: string): Promise<WorkspaceFileCatalogue> => {
990
+ if (workspaceFileCache?.cwd === cwd && workspaceFileCache.expiresAt > Date.now()) return Promise.resolve(workspaceFileCache.catalogue);
991
+ if (workspaceFileRequest?.cwd === cwd) return workspaceFileRequest.promise;
992
+ const promise = workspaceFiles(cwd)
993
+ .then((catalogue) => {
994
+ workspaceFileCache = { cwd, expiresAt: Date.now() + WORKSPACE_FILE_CACHE_MS, catalogue };
995
+ return catalogue;
996
+ })
997
+ .finally(() => {
998
+ if (workspaceFileRequest?.promise === promise) workspaceFileRequest = undefined;
999
+ });
1000
+ workspaceFileRequest = { cwd, promise };
1001
+ return promise;
1002
+ };
883
1003
  const events: StampedSessionEvent[] = [];
884
1004
  const eventClients = new Set<ServerResponse>();
885
1005
  const initialRunAt = Date.now();
@@ -1626,6 +1746,20 @@ export default {
1626
1746
  sendJson(response, 200, { items, ...(status.truncated ? { truncated: true } : {}) });
1627
1747
  },
1628
1748
  });
1749
+ const disposeWorkspaceFiles = services.webServer.register({
1750
+ path: "/api/workspace/files",
1751
+ async handler(_request, response) {
1752
+ try {
1753
+ const catalogue = await readWorkspaceFiles(activeCwd(services));
1754
+ sendJson(response, 200, {
1755
+ items: catalogue.paths.map((path) => ({ path, status: "", label: "workspace" })),
1756
+ truncated: catalogue.truncated,
1757
+ });
1758
+ } catch (error) {
1759
+ sendJson(response, 500, { error: errorText(error) });
1760
+ }
1761
+ },
1762
+ });
1629
1763
  const disposeFileDiff = services.webServer.register({
1630
1764
  path: "/api/files/diff",
1631
1765
  async handler(request, response) {
@@ -2339,6 +2473,7 @@ export default {
2339
2473
  disposeWorkspaces();
2340
2474
  disposePickDirectory();
2341
2475
  disposeFiles();
2476
+ disposeWorkspaceFiles();
2342
2477
  disposeFileDiff();
2343
2478
  disposeFileCommit();
2344
2479
  disposeFileRevert();
@@ -2380,6 +2380,101 @@ describe("API gateway plugin", () => {
2380
2380
  expect(invalid.status).toBe(400);
2381
2381
  });
2382
2382
 
2383
+ test("lists tracked and untracked workspace files independently from Git changes", async () => {
2384
+ const context = new Context();
2385
+ contexts.push(context);
2386
+ const workspace = await mkdtemp(join(tmpdir(), "pi-harness-workspace-files-"));
2387
+ temporaryDirectories.push(workspace);
2388
+ await execFile("git", ["init", "-q"], { cwd: workspace });
2389
+ await mkdir(join(workspace, "src"));
2390
+ await mkdir(join(workspace, "node_modules"));
2391
+ await writeFile(join(workspace, ".gitignore"), "ignored.log\nnode_modules/\n");
2392
+ await writeFile(join(workspace, "README.md"), "tracked\n");
2393
+ await writeFile(join(workspace, "src", "app.ts"), "export {};\n");
2394
+ await writeFile(join(workspace, "draft.md"), "untracked\n");
2395
+ await writeFile(join(workspace, "ignored.log"), "ignored\n");
2396
+ await writeFile(join(workspace, "node_modules", "dependency.js"), "ignored\n");
2397
+ await execFile("git", ["add", ".gitignore", "README.md", "src/app.ts"], { cwd: workspace });
2398
+ await context.plugin(webServerPlugin, { host: "127.0.0.1", port: 0 });
2399
+ const session = { sessionId: "workspace-files-session", sessionFile: undefined, messages: [], isStreaming: false, subscribe: () => () => {} };
2400
+ context.provide("piRuntime", { session, prompt: () => Promise.resolve(), abort: () => Promise.resolve(), dispose: () => Promise.resolve() } as never);
2401
+ context.provide("piModels", { model: { provider: "test", id: "model" }, runtime: { getModels: () => [], getModel: () => undefined } } as never);
2402
+ context.provide("piHarnessLaunch", { cwd: workspace, agentDir: "/tmp/agent", args: [], requestExit() {} });
2403
+ await context.plugin(apiPlugin);
2404
+
2405
+ const response = await fetch(context.webServer.url + "/api/workspace/files");
2406
+
2407
+ expect(response.status).toBe(200);
2408
+ await expect(response.json()).resolves.toEqual({
2409
+ items: [
2410
+ { path: ".gitignore", status: "", label: "workspace" },
2411
+ { path: "README.md", status: "", label: "workspace" },
2412
+ { path: "draft.md", status: "", label: "workspace" },
2413
+ { path: "src/app.ts", status: "", label: "workspace" },
2414
+ ],
2415
+ truncated: false,
2416
+ });
2417
+ });
2418
+
2419
+ test("lists bounded files in a non-Git workspace without following symlinks or generated directories", async () => {
2420
+ const context = new Context();
2421
+ contexts.push(context);
2422
+ const workspace = await mkdtemp(join(tmpdir(), "pi-harness-plain-workspace-files-"));
2423
+ const outside = await mkdtemp(join(tmpdir(), "pi-harness-plain-workspace-outside-"));
2424
+ temporaryDirectories.push(workspace, outside);
2425
+ await mkdir(join(workspace, "src"));
2426
+ await mkdir(join(workspace, "node_modules"));
2427
+ await writeFile(join(workspace, "README.md"), "root\n");
2428
+ await writeFile(join(workspace, "src", "app.ts"), "export {};\n");
2429
+ await writeFile(join(workspace, "node_modules", "dependency.js"), "ignored\n");
2430
+ await writeFile(join(outside, "secret.txt"), "outside\n");
2431
+ await symlink(join(outside, "secret.txt"), join(workspace, "linked-secret.txt"));
2432
+ await context.plugin(webServerPlugin, { host: "127.0.0.1", port: 0 });
2433
+ const session = { sessionId: "plain-workspace-files-session", sessionFile: undefined, messages: [], isStreaming: false, subscribe: () => () => {} };
2434
+ context.provide("piRuntime", { session, prompt: () => Promise.resolve(), abort: () => Promise.resolve(), dispose: () => Promise.resolve() } as never);
2435
+ context.provide("piModels", { model: { provider: "test", id: "model" }, runtime: { getModels: () => [], getModel: () => undefined } } as never);
2436
+ context.provide("piHarnessLaunch", { cwd: workspace, agentDir: "/tmp/agent", args: [], requestExit() {} });
2437
+ await context.plugin(apiPlugin);
2438
+
2439
+ const response = await fetch(context.webServer.url + "/api/workspace/files");
2440
+
2441
+ expect(response.status).toBe(200);
2442
+ await expect(response.json()).resolves.toEqual({
2443
+ items: [
2444
+ { path: "README.md", status: "", label: "workspace" },
2445
+ { path: "src/app.ts", status: "", label: "workspace" },
2446
+ ],
2447
+ truncated: false,
2448
+ });
2449
+ });
2450
+
2451
+ test("shares a short workspace catalogue cache across rapid console refreshes", async () => {
2452
+ const context = new Context();
2453
+ contexts.push(context);
2454
+ const workspace = await mkdtemp(join(tmpdir(), "pi-harness-workspace-file-cache-"));
2455
+ temporaryDirectories.push(workspace);
2456
+ const shimDirectory = join(workspace, "bin");
2457
+ const calls = join(workspace, "git-calls.txt");
2458
+ await mkdir(shimDirectory);
2459
+ await writeFile(join(workspace, "README.md"), "cached\n");
2460
+ await writeFile(join(shimDirectory, "git"), `#!/bin/sh\nprintf x >> ${JSON.stringify(calls)}\nprintf 'README.md\\0'\n`, { mode: 0o755 });
2461
+ await context.plugin(webServerPlugin, { host: "127.0.0.1", port: 0 });
2462
+ const session = { sessionId: "workspace-file-cache-session", sessionFile: undefined, messages: [], isStreaming: false, subscribe: () => () => {} };
2463
+ context.provide("piRuntime", { session, prompt: () => Promise.resolve(), abort: () => Promise.resolve(), dispose: () => Promise.resolve() } as never);
2464
+ context.provide("piModels", { model: { provider: "test", id: "model" }, runtime: { getModels: () => [], getModel: () => undefined } } as never);
2465
+ context.provide("piHarnessLaunch", { cwd: workspace, agentDir: "/tmp/agent", args: [], requestExit() {} });
2466
+ await context.plugin(apiPlugin);
2467
+ const originalPath = process.env.PATH ?? "";
2468
+ process.env.PATH = `${shimDirectory}:${originalPath}`;
2469
+ try {
2470
+ expect((await fetch(context.webServer.url + "/api/workspace/files")).status).toBe(200);
2471
+ expect((await fetch(context.webServer.url + "/api/workspace/files")).status).toBe(200);
2472
+ await expect(readFile(calls, "utf8")).resolves.toBe("x");
2473
+ } finally {
2474
+ process.env.PATH = originalPath;
2475
+ }
2476
+ });
2477
+
2383
2478
  test("lists the reviewed plugin marketplace and supports bounded filters", async () => {
2384
2479
  const context = new Context();
2385
2480
  contexts.push(context);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-harness/web-app",
3
- "version": "0.1.84",
3
+ "version": "0.1.86",
4
4
  "private": true,
5
5
  "description": "Patchable Web application bundle for Pi Harness",
6
6
  "homepage": "https://github.com/pi-harness/pi-harness#readme",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-harness/cli",
3
- "version": "0.1.84",
3
+ "version": "0.1.86",
4
4
  "private": true,
5
5
  "description": "Plugin-first Pi Harness CLI",
6
6
  "homepage": "https://github.com/pi-harness/pi-harness#readme",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-harness/host-webserver",
3
- "version": "0.1.84",
3
+ "version": "0.1.86",
4
4
  "private": true,
5
5
  "description": "Cordis route-registration service for the Pi Harness web host",
6
6
  "homepage": "https://github.com/pi-harness/pi-harness#readme",