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 +1 -1
- package/form/script.js +19 -5
- package/index.ts +3 -3
- package/package.json +1 -1
- package/server.ts +46 -32
- package/settings.ts +14 -5
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
|
-
|
|
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 || !
|
|
2411
|
-
if (
|
|
2412
|
-
setFieldError(
|
|
2418
|
+
if (!response.ok || !submitResult.ok) {
|
|
2419
|
+
if (submitResult.field) {
|
|
2420
|
+
setFieldError(submitResult.field, submitResult.error || "Invalid input");
|
|
2413
2421
|
} else {
|
|
2414
|
-
showGlobalError(
|
|
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
|
-
|
|
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
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|