partforge 0.4.0 → 0.5.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.
package/README.md CHANGED
@@ -50,6 +50,11 @@ hide internal params, and keep the interface simple while staying deeply adjusta
50
50
  `src/parts/demo.js` is a minimal worked example; run `npm run dev` and open
51
51
  `/demo.html` to see it live.
52
52
 
53
+ - **Agent clarification (`request-a-pick`):** an external tool can ask the user to click
54
+ geometry and get the `Selection` back — serve with `?pickserver`, drive with
55
+ `partforge pick-serve` + `partforge pick "<prompt>" …`. See
56
+ `skills/partforge/SKILL.md` and the authoring guide.
57
+
53
58
  ## License
54
59
 
55
60
  MIT
package/bin/cli.js CHANGED
@@ -8,54 +8,76 @@ import { detectBackend } from "../src/framework/geometry/probe.js";
8
8
  import { bootOcctKernel } from "../src/testing/occt.js";
9
9
  import { measure } from "../src/testing/measure.js";
10
10
  import { renderViews } from "../src/testing/render.js";
11
+ import { createPickServer, requestPicks, formatPickResult } from "../src/framework/pick-request/server.js";
11
12
 
12
13
  const die = (msg) => { console.error(msg); process.exit(1); };
13
14
  const slug = (s) => String(s).toLowerCase().replace(/\s+/g, "-");
14
15
 
15
- const [, , cmd, partPath, ...rest] = process.argv;
16
+ const [, , cmd, ...args] = process.argv;
16
17
  const flags = {};
17
18
  const positional = [];
18
- for (let i = 0; i < rest.length; i++) {
19
- if (rest[i].startsWith("--")) {
20
- const key = rest[i].slice(2);
21
- flags[key] = rest[i + 1] && !rest[i + 1].startsWith("--") ? rest[++i] : true;
22
- } else positional.push(rest[i]);
19
+ for (let i = 0; i < args.length; i++) {
20
+ if (args[i].startsWith("--")) {
21
+ const key = args[i].slice(2);
22
+ flags[key] = args[i + 1] && !args[i + 1].startsWith("--") ? args[++i] : true;
23
+ } else positional.push(args[i]);
23
24
  }
24
- const view = positional[0];
25
25
 
26
- if (!["measure", "render"].includes(cmd)) die("usage: partforge <measure|render> <part-module> [view] [flags]");
27
- if (!partPath) die(`usage: partforge ${cmd} <part-module> [view]`);
26
+ const USAGE = "usage: partforge <measure|render|pick-serve|pick> ";
28
27
 
29
- const mod = await import(pathToFileURL(resolve(process.cwd(), partPath)))
30
- .catch((e) => die(`cannot load part "${partPath}": ${e.message}`));
31
- const part = mod.default;
32
- if (!part?.parts || !part?.views) die(`"${partPath}" has no default-exported PartDefinition`);
33
-
34
- let kernel;
35
- if (detectBackend(part) === "occt") {
36
- kernel = await bootOcctKernel();
37
- } else {
38
- const wasm = await Module(); wasm.setup();
39
- kernel = createManifoldKernel(wasm, { quality: "preview" });
28
+ // --- pick-serve / pick: no part module, no kernel boot --------------------------
29
+ if (cmd === "pick-serve") {
30
+ const port = Number(flags.port) || 4518;
31
+ const timeoutMs = (Number(flags.timeout) || 120) * 1000;
32
+ const { port: bound } = await createPickServer({ port, timeoutMs }).start();
33
+ console.log(`partforge pick-server listening on http://127.0.0.1:${bound}`);
34
+ // keep the process alive serving requests
35
+ } else if (cmd === "pick") {
36
+ if (positional.length === 0) die('usage: partforge pick "<prompt>" ["<prompt>" …] [--port N]');
37
+ const port = Number(flags.port) || 4518;
38
+ const out = await requestPicks({ port, prompts: positional }).catch((e) => die(e.message));
39
+ console.log(formatPickResult(out));
40
+ process.exit(out.status === "done" ? 0 : 1);
41
+ } else if (!["measure", "render"].includes(cmd)) {
42
+ die(USAGE);
40
43
  }
41
44
 
42
- try {
43
- if (cmd === "measure") {
44
- const report = measure(kernel, part, view);
45
- printMeasure(report);
46
- const file = `measure-${slug(report.part)}-${report.view}.json`;
47
- writeFileSync(file, JSON.stringify(report, null, 2));
48
- console.log(`\nwrote ${file}`);
49
- if (flags.json) console.log(JSON.stringify(report, null, 2));
50
- process.exit(report.ok ? 0 : 1);
45
+ const partPath = positional[0];
46
+ const view = positional[1];
47
+ if (["measure", "render"].includes(cmd) && !partPath) die(`usage: partforge ${cmd} <part-module> [view]`);
48
+
49
+ if (["measure", "render"].includes(cmd)) {
50
+ const mod = await import(pathToFileURL(resolve(process.cwd(), partPath)))
51
+ .catch((e) => die(`cannot load part "${partPath}": ${e.message}`));
52
+ const part = mod.default;
53
+ if (!part?.parts || !part?.views) die(`"${partPath}" has no default-exported PartDefinition`);
54
+
55
+ let kernel;
56
+ if (detectBackend(part) === "occt") {
57
+ kernel = await bootOcctKernel();
51
58
  } else {
52
- const views = typeof flags.views === "string" ? flags.views.split(",") : undefined;
53
- const files = await renderViews(kernel, part, view, { views, out: flags.out || "render" });
54
- for (const f of files) console.log(`wrote ${f}`);
55
- process.exit(0);
59
+ const wasm = await Module(); wasm.setup();
60
+ kernel = createManifoldKernel(wasm, { quality: "preview" });
61
+ }
62
+
63
+ try {
64
+ if (cmd === "measure") {
65
+ const report = measure(kernel, part, view);
66
+ printMeasure(report);
67
+ const file = `measure-${slug(report.part)}-${report.view}.json`;
68
+ writeFileSync(file, JSON.stringify(report, null, 2));
69
+ console.log(`\nwrote ${file}`);
70
+ if (flags.json) console.log(JSON.stringify(report, null, 2));
71
+ process.exit(report.ok ? 0 : 1);
72
+ } else {
73
+ const views = typeof flags.views === "string" ? flags.views.split(",") : undefined;
74
+ const files = await renderViews(kernel, part, view, { views, out: flags.out || "render" });
75
+ for (const f of files) console.log(`wrote ${f}`);
76
+ process.exit(0);
77
+ }
78
+ } catch (e) {
79
+ die(`${cmd} failed: ${e.message || e}`);
56
80
  }
57
- } catch (e) {
58
- die(`${cmd} failed: ${e.message || e}`);
59
81
  }
60
82
 
61
83
  function printMeasure(r) {
@@ -467,3 +467,21 @@ entirely on OCCT, its fillets are exact in the STEP **and** present in the print
467
467
  `place(..., { purpose: "export" })` may depend on `view`.
468
468
  - Keep geometry backend-agnostic (kernel calls only) so it works in both backends; only
469
469
  STEP requires OCCT.
470
+
471
+ ---
472
+
473
+ ## Interactive clarification: request-a-pick
474
+
475
+ An external tool (e.g. an AI agent editing your part) can ask the *user* to click
476
+ geometry and receive the `Selection` back, closing the loop in the other direction
477
+ from `?pick`.
478
+
479
+ - Serve your app with **`?pickserver`** (or `?pickserver=http://host:port`) to enable
480
+ it. While idle nothing changes; when the local pick-server requests a click, a banner
481
+ appears ("🤖 Claude needs you to click …") and the picker arms for one click.
482
+ - The agent side runs `partforge pick-serve` once, then `partforge pick "<prompt>" …`
483
+ for one or more clicks (collected in order, returned together). The CLI blocks until
484
+ the user clicks, then prints the `Selection`(s) as JSON.
485
+
486
+ See the bundled skill `skills/partforge/SKILL.md` for the agent workflow. This is plain
487
+ click-routing — no LLM logic lives in partforge.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -16,6 +16,7 @@
16
16
  "files": [
17
17
  "src",
18
18
  "bin",
19
+ "skills/partforge/SKILL.md",
19
20
  "docs/AUTHORING-PARTS.md",
20
21
  "README.md"
21
22
  ],
@@ -0,0 +1,58 @@
1
+ ---
2
+ name: partforge-request-pick
3
+ description: Use when editing a partforge part for a user who has the live app open and you need them to point at geometry — ask for one or more clicks and get the Selection(s) back.
4
+ ---
5
+
6
+ # partforge: request-a-pick
7
+
8
+ When you're editing a partforge part and you're unsure *which* face, edge, hole, or
9
+ sub-part the user means, don't guess — ask them to click it in the live app. Their
10
+ click comes back to you as a structured `Selection` (sub-part, local CAD point,
11
+ surface normal, the parameters they were viewing).
12
+
13
+ ## When to use
14
+
15
+ - The user's request is geometrically ambiguous ("make this thicker", "fillet that
16
+ edge", "move the hole") and more than one feature could match.
17
+ - You need a concrete location/normal to drive an edit.
18
+
19
+ ## One-time setup (per session)
20
+
21
+ Start the pick-server (it bridges the app and this CLI). The user must have the app
22
+ open with `?pickserver` (e.g. `http://localhost:5173/?pickserver`).
23
+
24
+ ```bash
25
+ partforge pick-serve & # default http://127.0.0.1:4518
26
+ ```
27
+
28
+ ## Requesting clicks
29
+
30
+ Ask for one or many — they're collected in order and returned together:
31
+
32
+ ```bash
33
+ partforge pick "click the face you want filleted"
34
+ partforge pick "click the mounting hole" "click the top edge" "click the boss"
35
+ ```
36
+
37
+ Tell the user out loud to check their browser ("I've put a prompt in your browser —
38
+ click the face you want filleted"). The command **blocks** until they click (or
39
+ timeout), then prints a summary plus JSON:
40
+
41
+ ```json
42
+ { "status": "done", "picks": [ { "prompt": "...", "selection": { "subPart": "...", "point": [...], "normal": [...], "params": {...} } } ] }
43
+ ```
44
+
45
+ Picks come back **in request order**, each echoing its prompt, so you can map them.
46
+
47
+ ## Handling outcomes
48
+
49
+ - `done` — proceed with the returned `selection`(s).
50
+ - `timeout` — the user didn't click in time; `picks` holds any collected so far. Ask
51
+ again or fall back to asking in words.
52
+ - `cancelled` — the user clicked "Can't find it"; reconsider what you're asking for.
53
+ - `busy` (exit non-zero) — a request is already in flight; wait and retry.
54
+
55
+ ## Notes
56
+
57
+ - This only *reads* a click — it never edits files. You make the edits yourself after.
58
+ - The server is localhost-only and holds one request at a time.
@@ -163,4 +163,48 @@ button.action:disabled { opacity: .5; cursor: default; }
163
163
  /* relevance: controls/sections that don't affect the on-screen parts */
164
164
  .irrelevant { opacity: 0.45; }
165
165
  .irrelevant:hover { opacity: 0.7; }
166
- .section-hidden { display: none; }
166
+ .section-hidden { display: none; }
167
+
168
+ /* request-a-pick: agent prompt banner, floated top-centre well below the part tabs,
169
+ laid out like a chat message (avatar + text). Slides/fades in when shown; themed. */
170
+ #pf-pick-banner {
171
+ position: fixed; top: 78px; left: 50%; transform: translateX(-50%);
172
+ z-index: 30; max-width: min(56ch, calc(100vw - 24px));
173
+ padding: 10px 36px 10px 12px;
174
+ background: var(--surface); color: var(--text);
175
+ border: 1px solid var(--border); border-radius: 10px;
176
+ box-shadow: 0 6px 24px rgba(0,0,0,.35);
177
+ font-size: 12px; line-height: 1.45;
178
+ animation: pf-pick-in .18s ease;
179
+ }
180
+ #pf-pick-banner .pf-pick-row { display: flex; align-items: center; gap: 10px; }
181
+ #pf-pick-banner .pf-pick-avatar {
182
+ flex: none; width: 30px; height: 30px; border-radius: 50%; font-size: 17px;
183
+ display: flex; align-items: center; justify-content: center;
184
+ background: var(--surface-2); border: 1px solid var(--border);
185
+ }
186
+ #pf-pick-banner .pf-pick-msg { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
187
+ #pf-pick-banner .pf-pick-label { color: var(--muted); font-size: 11px; }
188
+ #pf-pick-banner .pf-pick-prompt { color: var(--text-strong); font-weight: 600; }
189
+ #pf-pick-close {
190
+ position: absolute; top: 6px; right: 6px; appearance: none;
191
+ width: 20px; height: 20px; padding: 0;
192
+ border: 1px solid var(--border); border-radius: 6px;
193
+ background: var(--surface-2); color: var(--muted);
194
+ cursor: pointer; font-size: 13px; line-height: 1;
195
+ display: flex; align-items: center; justify-content: center;
196
+ }
197
+ #pf-pick-close:hover { color: var(--text-strong); border-color: var(--accent); }
198
+ @keyframes pf-pick-in {
199
+ from { opacity: 0; transform: translateX(-50%) translateY(-10px); }
200
+ to { opacity: 1; transform: translateX(-50%) translateY(0); }
201
+ }
202
+ /* dev aid (request-a-pick mode): preview the prompt banner without an agent */
203
+ #pf-pick-test {
204
+ position: fixed; left: 12px; bottom: 12px; z-index: 30; appearance: none;
205
+ padding: 6px 10px; border: 1px solid var(--border); border-radius: 8px;
206
+ background: var(--surface); color: var(--muted-2); cursor: pointer;
207
+ font: 12px/1 -apple-system, system-ui, sans-serif;
208
+ box-shadow: 0 6px 24px rgba(0,0,0,.35);
209
+ }
210
+ #pf-pick-test:hover { color: var(--text); border-color: var(--accent); }
@@ -9,6 +9,7 @@ import { viewSubParts } from "./jobs.js";
9
9
  import { detectBackend } from "./geometry/probe.js";
10
10
  import { createDebugOverlay } from "./debug-overlay.js";
11
11
  import { attachPicker, formatSelection } from "./selection/index.js";
12
+ import { createPickRequestClient } from "./pick-request/index.js";
12
13
 
13
14
  // Mount a full parametric-part app from a PartDefinition: 3-D viewer + control
14
15
  // panel + the two geometry workers + the auto-regenerating view/cache loop +
@@ -108,6 +109,18 @@ export function mount(part, { createWorker, container = document.getElementById(
108
109
  picker.setActive(isActive);
109
110
  btn.style.outline = isActive ? "2px solid #ffcc33" : "";
110
111
  });
112
+ } else if (qs.has("pickserver")) {
113
+ // Agent-driven mode: arm the picker only when the local pick-server asks for a
114
+ // click. Mutually exclusive with the clipboard ?pick toggle (else-if), so only one
115
+ // click listener is ever live. `?pickserver` or `?pickserver=http://host:port`.
116
+ const serverUrl = typeof qs.get("pickserver") === "string" && qs.get("pickserver")
117
+ ? qs.get("pickserver") : "http://127.0.0.1:4518";
118
+ createPickRequestClient({
119
+ serverUrl,
120
+ viewer,
121
+ part,
122
+ getContext: () => ({ view, params, derived: part.derive ? part.derive({ ...part.defaults, ...params }) : {} }),
123
+ });
111
124
  }
112
125
 
113
126
  let framedView = null; // the view the camera was last framed to (null until first show)
@@ -0,0 +1,42 @@
1
+ // Pure state machine for one batch of click requests: ordered prompts in,
2
+ // ordered picks out. No http, no DOM, no timers (the server owns the clock).
3
+ import { randomUUID } from "node:crypto";
4
+
5
+ export function createBatch(prompts) {
6
+ return { id: randomUUID(), prompts: [...prompts], picks: [], index: 0, status: "collecting" };
7
+ }
8
+
9
+ export function view(batch) {
10
+ const collecting = batch.status === "collecting";
11
+ return {
12
+ id: batch.id,
13
+ index: batch.index,
14
+ total: batch.prompts.length,
15
+ prompt: collecting ? batch.prompts[batch.index] : null,
16
+ status: batch.status,
17
+ };
18
+ }
19
+
20
+ // Record a click for the current step. Ignores a non-collecting batch or an index
21
+ // that isn't the one we're waiting on (stale/duplicate click guard).
22
+ export function resolve(batch, index, selection) {
23
+ if (batch.status !== "collecting" || index !== batch.index) return batch;
24
+ batch.picks.push({ prompt: batch.prompts[batch.index], selection });
25
+ batch.index += 1;
26
+ if (batch.index >= batch.prompts.length) batch.status = "done";
27
+ return batch;
28
+ }
29
+
30
+ export function cancel(batch) {
31
+ if (batch.status === "collecting") batch.status = "cancelled";
32
+ return batch;
33
+ }
34
+
35
+ export function timeout(batch) {
36
+ if (batch.status === "collecting") batch.status = "timeout";
37
+ return batch;
38
+ }
39
+
40
+ export function result(batch) {
41
+ return { status: batch.status, picks: batch.picks };
42
+ }
@@ -0,0 +1,100 @@
1
+ // Browser side of request-a-pick: subscribe to the pick-server, show a prompt banner,
2
+ // arm the existing picker on demand, and POST each click back. Self-created DOM; the
3
+ // look (position, theme colours, slide-in animation) lives in app.css (#pf-pick-banner),
4
+ // so it follows the app's light/dark theme. Visibility is toggled via display.
5
+ import { attachPicker } from "../selection/pick.js";
6
+
7
+ export function createPickRequestClient({ serverUrl = "http://127.0.0.1:4518", viewer, part, getContext }) {
8
+ let active = null; // { id, index } of the prompt we're waiting on
9
+
10
+ // --- prompt banner: chat-style (avatar + message) ---------------------------
11
+ const banner = document.createElement("div");
12
+ banner.id = "pf-pick-banner";
13
+ banner.style.display = "none";
14
+
15
+ const row = document.createElement("div");
16
+ row.className = "pf-pick-row";
17
+ const avatar = document.createElement("div");
18
+ avatar.className = "pf-pick-avatar";
19
+ avatar.textContent = "🤖";
20
+ const msg = document.createElement("div");
21
+ msg.className = "pf-pick-msg";
22
+ const label = document.createElement("div");
23
+ label.className = "pf-pick-label";
24
+ const prompt = document.createElement("div");
25
+ prompt.className = "pf-pick-prompt";
26
+ msg.append(label, prompt);
27
+ row.append(avatar, msg);
28
+
29
+ const close = document.createElement("button");
30
+ close.id = "pf-pick-close";
31
+ close.type = "button";
32
+ close.textContent = "×";
33
+ close.setAttribute("aria-label", "Dismiss");
34
+
35
+ banner.append(row, close);
36
+ document.body.appendChild(banner);
37
+
38
+ const show = () => { banner.style.display = "block"; };
39
+ const hide = () => { banner.style.display = "none"; };
40
+ const showError = (text) => { label.textContent = ""; prompt.textContent = text; show(); };
41
+
42
+ // Render a prompt as a chat message. The prompt text comes from the agent, so it goes
43
+ // through textContent (never innerHTML) to stay injection-safe.
44
+ const showPrompt = (v) => {
45
+ label.textContent = v.total > 1 ? `Your agent asks: (${v.index + 1} of ${v.total})` : "Your agent asks:";
46
+ prompt.textContent = v.prompt;
47
+ show();
48
+ };
49
+
50
+ const postJson = (path, body) =>
51
+ fetch(`${serverUrl}${path}`, {
52
+ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body),
53
+ }).catch(() => showError("⚠ couldn't reach pick-server — click not sent"));
54
+
55
+ const picker = attachPicker(viewer, {
56
+ part, getContext,
57
+ onPick: (selection) => {
58
+ if (!active) return;
59
+ postJson("/resolve", { id: active.id, index: active.index, selection });
60
+ },
61
+ });
62
+
63
+ // The × dismisses the request (cancels the active batch on the server) and hides the
64
+ // banner immediately for responsiveness.
65
+ close.addEventListener("click", () => {
66
+ if (active) postJson("/cancel", { id: active.id });
67
+ hide();
68
+ });
69
+
70
+ // --- dev test button: preview the banner locally, no agent round-trip -------
71
+ const test = document.createElement("button");
72
+ test.id = "pf-pick-test";
73
+ test.type = "button";
74
+ test.textContent = "🤖 test prompt";
75
+ test.title = "Preview the agent prompt banner (local, no agent)";
76
+ test.addEventListener("click", () => {
77
+ if (banner.style.display !== "none") { hide(); return; } // toggle off (and re-arm the animation)
78
+ showPrompt({ index: 0, total: 1, prompt: "Click a face — this is a test prompt" });
79
+ });
80
+ document.body.appendChild(test);
81
+
82
+ const es = new globalThis.EventSource(`${serverUrl}/events`);
83
+ es.addEventListener("prompt", (e) => {
84
+ const v = JSON.parse(e.data);
85
+ active = { id: v.id, index: v.index };
86
+ showPrompt(v);
87
+ picker.setActive(true);
88
+ });
89
+ es.addEventListener("cleared", () => {
90
+ active = null;
91
+ hide();
92
+ picker.setActive(false);
93
+ });
94
+ es.onerror = () => { showError("⚠ agent pick-server not reachable"); };
95
+ es.onopen = () => { if (!active) hide(); };
96
+
97
+ return {
98
+ detach: () => { es.close(); picker.detach(); banner.remove(); test.remove(); },
99
+ };
100
+ }
@@ -0,0 +1 @@
1
+ export { createPickRequestClient } from "./client.js";
@@ -0,0 +1,158 @@
1
+ // src/framework/pick-request/server.js
2
+ // The Node side of request-a-pick: an http+SSE server holding one active batch,
3
+ // a blocking CLI client (requestPicks), and CLI output formatting. 127.0.0.1 only.
4
+ import { createServer, request as httpRequest } from "node:http";
5
+ import { createBatch, view, resolve, cancel, timeout, result } from "./batch.js";
6
+ import { formatSelection } from "../selection/format.js";
7
+
8
+ const json = (res, code, obj, origin) => {
9
+ res.writeHead(code, { "content-type": "application/json", "access-control-allow-origin": origin || "*" });
10
+ res.end(JSON.stringify(obj));
11
+ };
12
+ const readBody = (req) => new Promise((resolve_) => {
13
+ let b = "";
14
+ req.on("data", (c) => (b += c));
15
+ req.on("end", () => {
16
+ if (!b) { resolve_({}); return; }
17
+ try { resolve_(JSON.parse(b)); } catch { resolve_({ _parseError: true }); }
18
+ });
19
+ });
20
+
21
+ export function createPickServer({ port = 4518, timeoutMs = 120000 } = {}) {
22
+ let batch = null; // the one active batch (or null)
23
+ let pending = null; // { res, timer } — the held POST /request response
24
+ const sseClients = new Set();
25
+ const allSockets = new Set(); // track every socket for forceful teardown
26
+
27
+ const sse = (event, data) => {
28
+ for (const res of sseClients) res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
29
+ };
30
+ const finish = () => { // resolve the held /request with the result
31
+ if (pending) {
32
+ clearTimeout(pending.timer);
33
+ json(pending.res, 200, result(batch));
34
+ pending = null;
35
+ }
36
+ sse("cleared", {});
37
+ batch = null;
38
+ };
39
+
40
+ const server = createServer(async (req, res) => {
41
+ const origin = req.headers.origin;
42
+ if (req.method === "OPTIONS") {
43
+ res.writeHead(204, {
44
+ "access-control-allow-origin": origin || "*",
45
+ "access-control-allow-methods": "GET,POST,OPTIONS",
46
+ "access-control-allow-headers": "content-type",
47
+ });
48
+ return res.end();
49
+ }
50
+ const url = req.url.split("?")[0];
51
+
52
+ if (req.method === "POST" && url === "/request") {
53
+ if (batch) return json(res, 409, { status: "busy" }, origin);
54
+ const body = await readBody(req);
55
+ if (body._parseError) return json(res, 400, { error: "invalid JSON" }, origin);
56
+ if (!Array.isArray(body.prompts) || body.prompts.length === 0) {
57
+ return json(res, 400, { error: "prompts must be a non-empty array" }, origin);
58
+ }
59
+ batch = createBatch(body.prompts);
60
+ pending = { res, timer: setTimeout(() => { timeout(batch); finish(); }, timeoutMs) };
61
+ sse("prompt", view(batch));
62
+ return; // held open until finish()
63
+ }
64
+ if (req.method === "GET" && url === "/events") {
65
+ res.writeHead(200, {
66
+ "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive",
67
+ "access-control-allow-origin": origin || "*",
68
+ });
69
+ res.write(": connected\n\n"); // SSE comment — flushes headers, makes fetch() resolve
70
+ sseClients.add(res);
71
+ if (batch) res.write(`event: prompt\ndata: ${JSON.stringify(view(batch))}\n\n`); // replay current
72
+ req.on("close", () => sseClients.delete(res));
73
+ return;
74
+ }
75
+ if (req.method === "POST" && url === "/resolve") {
76
+ const body = await readBody(req);
77
+ if (body._parseError) return json(res, 400, { error: "invalid JSON" }, origin);
78
+ const { id, index, selection } = body;
79
+ if (batch && id === batch.id) {
80
+ resolve(batch, index, selection);
81
+ if (view(batch).status === "collecting") sse("prompt", view(batch));
82
+ else finish();
83
+ }
84
+ return json(res, 200, { ok: true }, origin);
85
+ }
86
+ if (req.method === "POST" && url === "/cancel") {
87
+ const body = await readBody(req);
88
+ if (body._parseError) return json(res, 400, { error: "invalid JSON" }, origin);
89
+ const { id } = body;
90
+ if (batch && id === batch.id) { cancel(batch); finish(); }
91
+ return json(res, 200, { ok: true }, origin);
92
+ }
93
+ return json(res, 404, { error: "not found" }, origin);
94
+ });
95
+
96
+ server.on("connection", (socket) => {
97
+ allSockets.add(socket);
98
+ socket.on("close", () => allSockets.delete(socket));
99
+ });
100
+
101
+ return {
102
+ start: () => new Promise((res_) => server.listen(port, "127.0.0.1", () => res_({ port: server.address().port }))),
103
+ stop: () => new Promise((res_) => {
104
+ // If a batch is active, cancel it and resolve the held /request cleanly
105
+ // so any awaiting requestPicks() gets a result instead of a socket hang-up.
106
+ if (batch) { cancel(batch); finish(); }
107
+ for (const c of sseClients) c.end();
108
+ server.close(() => res_());
109
+ // destroy any lingering sockets (SSE keep-alive) so server.close resolves
110
+ for (const s of allSockets) s.destroy();
111
+ }),
112
+ };
113
+ }
114
+
115
+ // CLI client: POST the prompts and await the held response (blocks until the batch
116
+ // reaches a terminal status server-side). Fails fast with a hint if nothing answers.
117
+ export function requestPicks({ port = 4518, host = "127.0.0.1", prompts }) {
118
+ return new Promise((resolve_, reject) => {
119
+ const payload = JSON.stringify({ prompts });
120
+ const req = httpRequest(
121
+ { host, port, path: "/request", method: "POST", headers: { "content-type": "application/json", "content-length": Buffer.byteLength(payload) } },
122
+ (res) => {
123
+ let b = "";
124
+ res.on("data", (c) => (b += c));
125
+ res.on("end", () => {
126
+ try {
127
+ resolve_(JSON.parse(b));
128
+ } catch {
129
+ reject(new Error(`unexpected response from pick-server on ${host}:${port} (is the app open and \`partforge pick-serve\` running?)`));
130
+ }
131
+ });
132
+ },
133
+ );
134
+ req.on("error", (e) => reject(new Error(`could not reach pick-server on ${host}:${port} (is the app open and \`partforge pick-serve\` running?) — ${e.message}`)));
135
+ req.end(payload);
136
+ });
137
+ }
138
+
139
+ // Human-readable CLI output: one summary line per pick, then the raw JSON to parse.
140
+ export function formatPickResult({ status, picks }) {
141
+ const safePicks = Array.isArray(picks) ? picks : [];
142
+ const lines = [`status: ${status} (${safePicks.length} pick${safePicks.length === 1 ? "" : "s"})`];
143
+ for (const { prompt, selection } of safePicks) {
144
+ let summary;
145
+ try {
146
+ if (selection && selection.point && selection.normal && selection.params) {
147
+ summary = formatSelection(selection, { style: "prompt" });
148
+ } else {
149
+ summary = selection?.subPart ?? JSON.stringify(selection);
150
+ }
151
+ } catch {
152
+ summary = JSON.stringify(selection);
153
+ }
154
+ lines.push(`• "${prompt}" → ${summary}`);
155
+ }
156
+ lines.push("", JSON.stringify({ status, picks }, null, 2));
157
+ return lines.join("\n");
158
+ }