@pi-harness/pi-harness 0.1.90 → 0.1.92

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.
@@ -2303,6 +2303,15 @@ export default {
2303
2303
  sendJson(response, 400, { error: "Invalid session path" });
2304
2304
  return;
2305
2305
  }
2306
+ // Pi defers creating a JSONL file for a new empty session until its first assistant response. A duplicate is still a durable action, so materialize the active session before listing it; missing non-active paths remain a 404.
2307
+ if (sourcePath === services.runtime.session.sessionFile && !existsSync(sourcePath)) {
2308
+ try {
2309
+ persistSessionBeforeFirstAssistant(manager);
2310
+ } catch (error) {
2311
+ // Another tab may win the first-persistence race between existsSync and writeFileSync; its file is the same fork source, so continue with the list.
2312
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
2313
+ }
2314
+ }
2306
2315
  const sessions = await SessionManager.list(activeCwd(services), manager.getSessionDir());
2307
2316
  const source = sessions.find((item) => item.path === sourcePath);
2308
2317
  if (!source) {
@@ -2397,6 +2406,15 @@ export default {
2397
2406
  sendJson(response, 400, { error: "Invalid session path" });
2398
2407
  return;
2399
2408
  }
2409
+ // Pi defers creating the JSONL file for a new empty session until its first entry. Exporting that active session is still a durable user action, so materialize its in-memory header and entries before reading it; missing non-active paths remain a 404.
2410
+ if (path === services.runtime.session.sessionFile && !existsSync(path)) {
2411
+ try {
2412
+ persistSessionBeforeFirstAssistant(manager);
2413
+ } catch (error) {
2414
+ // Another tab may win the first-persistence race between existsSync and writeFileSync; its file is the same export target, so continue with the read.
2415
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
2416
+ }
2417
+ }
2400
2418
  const content = await readFile(path, "utf8");
2401
2419
  response.writeHead(200, {
2402
2420
  "content-type": "application/x-ndjson; charset=utf-8",
@@ -2093,6 +2093,51 @@ describe("API gateway plugin", () => {
2093
2093
  expect(sessionPayload.items).toEqual(expect.arrayContaining([expect.objectContaining({ sessionId: fork.sessionId, forked: true })]));
2094
2094
  });
2095
2095
 
2096
+ test("forks an empty active session before its JSONL file is created", async () => {
2097
+ const context = new Context();
2098
+ contexts.push(context);
2099
+ await context.plugin(webServerPlugin, { host: "127.0.0.1", port: 0 });
2100
+ const directory = await mkdtemp(join(tmpdir(), "pi-harness-api-active-empty-fork-"));
2101
+ temporaryDirectories.push(directory);
2102
+ const launchCwd = join(directory, "launch");
2103
+ const activeCwd = join(directory, "active");
2104
+ const manager = SessionManager.create(activeCwd, directory);
2105
+ const sourcePath = manager.getSessionFile();
2106
+ expect(sourcePath).toBeDefined();
2107
+ const session = {
2108
+ get sessionId() {
2109
+ return manager.getSessionId();
2110
+ },
2111
+ get sessionFile() {
2112
+ return manager.getSessionFile();
2113
+ },
2114
+ get messages() {
2115
+ return manager.buildSessionContext().messages;
2116
+ },
2117
+ isStreaming: false,
2118
+ sessionManager: manager,
2119
+ subscribe: () => () => {},
2120
+ };
2121
+ context.provide("piRuntime", { session, sessionRuntime: { cwd: activeCwd }, prompt: () => Promise.resolve() } as never);
2122
+ context.provide("piModels", { model: { provider: "test", id: "model" } } as never);
2123
+ context.provide("piHarnessLaunch", { cwd: launchCwd, agentDir: directory, args: [], requestExit() {} });
2124
+ await context.plugin(apiPlugin);
2125
+
2126
+ const response = await fetch(context.webServer.url + "/api/session/fork", {
2127
+ method: "POST",
2128
+ headers: { "content-type": "application/json" },
2129
+ body: JSON.stringify({ path: sourcePath }),
2130
+ });
2131
+
2132
+ expect(response.status).toBe(200);
2133
+ const fork = (await response.json()) as { sessionId: string; sessionFile?: string; cwd: string };
2134
+ expect(fork).toMatchObject({ cwd: activeCwd });
2135
+ expect(fork.sessionFile).toBeDefined();
2136
+ expect(fork.sessionFile).not.toBe(sourcePath);
2137
+ const sourceContent = await readFile(sourcePath ?? "", "utf8");
2138
+ expect(sourceContent).toContain('"type":"session"');
2139
+ });
2140
+
2096
2141
  test("opens a forked persisted session from the session list and exposes its relationship", async () => {
2097
2142
  const context = new Context();
2098
2143
  contexts.push(context);
@@ -3805,6 +3850,39 @@ describe("API gateway plugin", () => {
3805
3850
  await expect(stat(target)).rejects.toMatchObject({ code: "ENOENT" });
3806
3851
  });
3807
3852
 
3853
+ test("exports an empty active session by persisting its in-memory header", async () => {
3854
+ const context = new Context();
3855
+ contexts.push(context);
3856
+ const directory = await mkdtemp(join(tmpdir(), "pi-harness-api-empty-session-export-"));
3857
+ temporaryDirectories.push(directory);
3858
+ const sessionDir = join(directory, "sessions");
3859
+ await mkdir(sessionDir);
3860
+ await context.plugin(webServerPlugin, { host: "127.0.0.1", port: 0 });
3861
+ const manager = SessionManager.create("/tmp", sessionDir);
3862
+ const target = manager.getSessionFile();
3863
+ if (!target) throw new Error("Expected a new session path");
3864
+ const session = {
3865
+ sessionId: manager.getSessionId(),
3866
+ get sessionFile() {
3867
+ return manager.getSessionFile();
3868
+ },
3869
+ messages: [],
3870
+ isStreaming: false,
3871
+ sessionManager: manager,
3872
+ subscribe: () => () => {},
3873
+ };
3874
+ context.provide("piRuntime", { session, prompt: () => Promise.resolve() } as never);
3875
+ context.provide("piModels", { model: { provider: "test", id: "model" } } as never);
3876
+ context.provide("piHarnessLaunch", { cwd: "/tmp", agentDir: "/tmp/agent", args: [], requestExit() {} });
3877
+ await context.plugin(apiPlugin);
3878
+
3879
+ const response = await fetch(context.webServer.url + "/api/session/export");
3880
+ expect(response.status).toBe(200);
3881
+ expect(response.headers.get("content-type")).toContain("application/x-ndjson");
3882
+ expect(await response.text()).toContain('"type":"session"');
3883
+ await expect(stat(target)).resolves.toBeDefined();
3884
+ });
3885
+
3808
3886
  test("keeps batch deletion going past a failing session and persists the metadata it did remove", async () => {
3809
3887
  const context = new Context();
3810
3888
  contexts.push(context);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-harness/web-app",
3
- "version": "0.1.90",
3
+ "version": "0.1.92",
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.90",
3
+ "version": "0.1.92",
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.90",
3
+ "version": "0.1.92",
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",