pi-interview 0.5.4 → 0.5.5

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.
package/README.md CHANGED
@@ -63,7 +63,7 @@ Restart pi to load the extension.
63
63
 
64
64
  **Timeout behavior:** The countdown (visible in corner) resets on any activity - typing, clicking, or mouse movement. When it expires, an overlay appears giving the user a chance to continue. Progress is never lost thanks to localStorage auto-save.
65
65
 
66
- **Multi-agent behavior:** When multiple agents run interviews simultaneously, only the first auto-opens the window. Subsequent interviews are queued and shown as a URL in the tool output, preventing focus stealing. Active interviews also surface a top-right toast with a dropdown to open queued sessions. A session status bar at the top of each form shows the project path, git branch, and session ID for easy identification.
66
+ **Multi-agent behavior:** When multiple agents run interviews simultaneously, only the first auto-opens the window. Subsequent interviews are queued and shown as a URL in the tool output, preventing focus stealing. When you submit the active interview, the window automatically redirects to the next queued interview. Active interviews also surface a top-right toast with a dropdown to open queued sessions. A session status bar at the top of each form shows the project path, git branch, and session ID for easy identification.
67
67
 
68
68
  ## Usage
69
69
 
package/form/script.js CHANGED
@@ -2405,13 +2405,21 @@
2405
2405
  body: JSON.stringify({ token: sessionToken, ...payload }),
2406
2406
  });
2407
2407
 
2408
- const result = await response.json().catch(() => ({ ok: false, error: "Invalid server response" }));
2408
+ let submitResult;
2409
+ try {
2410
+ submitResult = await response.json();
2411
+ } catch (err) {
2412
+ const message = err instanceof Error ? err.message : String(err);
2413
+ showGlobalError(`Invalid server response: ${message}`);
2414
+ submitBtn.disabled = false;
2415
+ return;
2416
+ }
2409
2417
 
2410
- if (!response.ok || !result.ok) {
2411
- if (result.field) {
2412
- setFieldError(result.field, result.error || "Invalid input");
2418
+ if (!response.ok || !submitResult.ok) {
2419
+ if (submitResult.field) {
2420
+ setFieldError(submitResult.field, submitResult.error || "Invalid input");
2413
2421
  } else {
2414
- showGlobalError(result.error || "Submission failed.");
2422
+ showGlobalError(submitResult.error || "Submission failed.");
2415
2423
  }
2416
2424
  submitBtn.disabled = false;
2417
2425
  return;
@@ -2427,6 +2435,12 @@
2427
2435
  stopHeartbeat();
2428
2436
  stopQueuePolling();
2429
2437
  session.ended = true;
2438
+
2439
+ if (submitResult.nextUrl) {
2440
+ window.location.href = submitResult.nextUrl;
2441
+ return;
2442
+ }
2443
+
2430
2444
  successOverlay.classList.remove("hidden");
2431
2445
  setTimeout(() => {
2432
2446
  closeWindow();
package/index.ts CHANGED
@@ -242,8 +242,9 @@ function loadSavedInterview(html: string, filePath: string): SavedQuestionsFile
242
242
  let data: unknown;
243
243
  try {
244
244
  data = JSON.parse(match[1]);
245
- } catch {
246
- throw new Error("Invalid saved interview: malformed JSON");
245
+ } catch (err) {
246
+ const message = err instanceof Error ? err.message : String(err);
247
+ throw new Error(`Invalid saved interview: malformed JSON (${message})`);
247
248
  }
248
249
 
249
250
  const raw = data as Record<string, unknown>;
@@ -412,7 +413,6 @@ export default function (pi: ExtensionAPI) {
412
413
  let glimpseWin: GlimpseWindow | null = null;
413
414
  let resolved = false;
414
415
  let url = "";
415
-
416
416
  const cleanup = () => {
417
417
  if (server) {
418
418
  server.close();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-interview",
3
- "version": "0.5.4",
3
+ "version": "0.5.5",
4
4
  "description": "Interactive interview form extension for pi coding agent",
5
5
  "author": "Nico Bailon",
6
6
  "license": "MIT",
package/server.ts CHANGED
@@ -303,8 +303,9 @@ async function parseJSONBody(req: IncomingMessage): Promise<unknown> {
303
303
  req.on("end", () => {
304
304
  try {
305
305
  resolve(JSON.parse(body));
306
- } catch {
307
- reject(new Error("Invalid JSON"));
306
+ } catch (err) {
307
+ const message = err instanceof Error ? err.message : String(err);
308
+ reject(new Error(`Invalid JSON: ${message}`));
308
309
  }
309
310
  });
310
311
 
@@ -807,6 +808,7 @@ export async function startInterviewServer(
807
808
  let browserConnected = false;
808
809
  let lastHeartbeatAt = Date.now();
809
810
  let watchdog: NodeJS.Timeout | null = null;
811
+ let sessionKeepAlive: NodeJS.Timeout | null = null;
810
812
  let completed = false;
811
813
 
812
814
  const stopWatchdog = () => {
@@ -816,10 +818,18 @@ export async function startInterviewServer(
816
818
  }
817
819
  };
818
820
 
821
+ const stopSessionKeepAlive = () => {
822
+ if (sessionKeepAlive) {
823
+ clearInterval(sessionKeepAlive);
824
+ sessionKeepAlive = null;
825
+ }
826
+ };
827
+
819
828
  const markCompleted = () => {
820
829
  if (completed) return false;
821
830
  completed = true;
822
831
  stopWatchdog();
832
+ stopSessionKeepAlive();
823
833
  return true;
824
834
  };
825
835
 
@@ -839,6 +849,20 @@ export async function startInterviewServer(
839
849
  const url = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`);
840
850
  log(verbose, `${method} ${url.pathname}`);
841
851
 
852
+ const parseBodyOrRespond = async (): Promise<unknown | null> => {
853
+ try {
854
+ return await parseJSONBody(req);
855
+ } catch (err) {
856
+ if (err instanceof BodyTooLargeError) {
857
+ sendJson(res, err.statusCode, { ok: false, error: err.message });
858
+ return null;
859
+ }
860
+ const message = err instanceof Error ? err.message : String(err);
861
+ sendJson(res, 400, { ok: false, error: message });
862
+ return null;
863
+ }
864
+ };
865
+
842
866
  if (method === "GET" && url.pathname === "/") {
843
867
  if (!validateTokenQuery(url, sessionToken, res)) return;
844
868
  touchHeartbeat();
@@ -978,11 +1002,8 @@ export async function startInterviewServer(
978
1002
  }
979
1003
 
980
1004
  if (method === "POST" && url.pathname === "/heartbeat") {
981
- const body = await parseJSONBody(req).catch(() => null);
982
- if (!body) {
983
- sendJson(res, 400, { ok: false, error: "Invalid body" });
984
- return;
985
- }
1005
+ const body = await parseBodyOrRespond();
1006
+ if (!body) return;
986
1007
  if (!validateTokenBody(body, sessionToken, res)) return;
987
1008
  touchHeartbeat();
988
1009
  sendJson(res, 200, { ok: true });
@@ -990,14 +1011,7 @@ export async function startInterviewServer(
990
1011
  }
991
1012
 
992
1013
  if (method === "POST" && url.pathname === "/cancel") {
993
- const body = await parseJSONBody(req).catch((err) => {
994
- if (err instanceof BodyTooLargeError) {
995
- sendJson(res, err.statusCode, { ok: false, error: err.message });
996
- return null;
997
- }
998
- sendJson(res, 400, { ok: false, error: err.message });
999
- return null;
1000
- });
1014
+ const body = await parseBodyOrRespond();
1001
1015
  if (!body) return;
1002
1016
  if (!validateTokenBody(body, sessionToken, res)) return;
1003
1017
  if (completed) {
@@ -1020,14 +1034,7 @@ export async function startInterviewServer(
1020
1034
  }
1021
1035
 
1022
1036
  if (method === "POST" && url.pathname === "/submit") {
1023
- const body = await parseJSONBody(req).catch((err) => {
1024
- if (err instanceof BodyTooLargeError) {
1025
- sendJson(res, err.statusCode, { ok: false, error: err.message });
1026
- return null;
1027
- }
1028
- sendJson(res, 400, { ok: false, error: err.message });
1029
- return null;
1030
- });
1037
+ const body = await parseBodyOrRespond();
1031
1038
  if (!body) return;
1032
1039
  if (!validateTokenBody(body, sessionToken, res)) return;
1033
1040
  if (completed) {
@@ -1143,20 +1150,22 @@ export async function startInterviewServer(
1143
1150
 
1144
1151
  markCompleted();
1145
1152
  unregisterSession(sessionId);
1146
- sendJson(res, 200, { ok: true });
1153
+ const nextSession = getActiveSessions()
1154
+ .filter((s) => s.id !== sessionId)
1155
+ .sort((a, b) => {
1156
+ if (a.startedAt !== b.startedAt) {
1157
+ return a.startedAt - b.startedAt;
1158
+ }
1159
+ return a.id.localeCompare(b.id);
1160
+ })[0];
1161
+ const nextUrl = nextSession ? nextSession.url : null;
1162
+ sendJson(res, 200, { ok: true, nextUrl });
1147
1163
  setImmediate(() => callbacks.onSubmit(responses));
1148
1164
  return;
1149
1165
  }
1150
1166
 
1151
1167
  if (method === "POST" && url.pathname === "/save") {
1152
- const body = await parseJSONBody(req).catch((err) => {
1153
- if (err instanceof BodyTooLargeError) {
1154
- sendJson(res, err.statusCode, { ok: false, error: err.message });
1155
- return null;
1156
- }
1157
- sendJson(res, 400, { ok: false, error: err.message });
1158
- return null;
1159
- });
1168
+ const body = await parseBodyOrRespond();
1160
1169
  if (!body) return;
1161
1170
  if (!validateTokenBody(body, sessionToken, res)) return;
1162
1171
  // Note: don't check `completed` - allow save after submit
@@ -1307,6 +1316,11 @@ export async function startInterviewServer(
1307
1316
  lastSeen: now,
1308
1317
  };
1309
1318
  registerSession(sessionEntry);
1319
+ const keepAliveEntry = sessionEntry;
1320
+ sessionKeepAlive = setInterval(() => {
1321
+ if (completed) return;
1322
+ touchSession(keepAliveEntry);
1323
+ }, 10000);
1310
1324
  if (!watchdog) {
1311
1325
  watchdog = setInterval(() => {
1312
1326
  if (completed || !browserConnected) return;
package/settings.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { readFileSync } from "node:fs";
1
+ import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { homedir } from "node:os";
4
4
 
@@ -22,10 +22,19 @@ export interface InterviewSettings {
22
22
  }
23
23
 
24
24
  export function loadSettings(): InterviewSettings {
25
- try {
26
- const data = JSON.parse(readFileSync(SETTINGS_PATH, "utf-8"));
27
- return (data.interview as InterviewSettings) ?? {};
28
- } catch {
25
+ if (!existsSync(SETTINGS_PATH)) {
29
26
  return {};
30
27
  }
28
+
29
+ const parsed = JSON.parse(readFileSync(SETTINGS_PATH, "utf-8"));
30
+ if (typeof parsed !== "object" || parsed === null) {
31
+ return {};
32
+ }
33
+
34
+ const interview = (parsed as Record<string, unknown>).interview;
35
+ if (typeof interview !== "object" || interview === null) {
36
+ return {};
37
+ }
38
+
39
+ return interview as InterviewSettings;
31
40
  }