@youdie006/prodex 0.40.44 → 0.40.46
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/dist/browser-send-lock.js +8 -0
- package/dist/chatgpt-browser.js +71 -20
- package/dist/cli-pro.js +63 -34
- package/dist/config.js +5 -0
- package/dist/store.js +7 -0
- package/dist/tui.js +79 -8
- package/docs/releasing.md +41 -0
- package/package.json +1 -1
|
@@ -12,6 +12,14 @@ function lockPath() {
|
|
|
12
12
|
return override;
|
|
13
13
|
return path.join(os.homedir(), ".local", "share", "prodex", "browser-send.lock");
|
|
14
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* When the last visible-browser send on this machine started. It sits beside
|
|
17
|
+
* the send lock because it paces the same shared browser: kept per repo, sends
|
|
18
|
+
* from two repos (or two worktrees of one) were never paced against each other.
|
|
19
|
+
*/
|
|
20
|
+
export function sendPacingMarkerPath() {
|
|
21
|
+
return path.join(path.dirname(lockPath()), "last-browser-send");
|
|
22
|
+
}
|
|
15
23
|
/**
|
|
16
24
|
* Serialize visible-browser sends across processes. Waits up to waitMs for a
|
|
17
25
|
* live holder to finish (0 = fail fast); a lock whose holder process is dead
|
package/dist/chatgpt-browser.js
CHANGED
|
@@ -235,6 +235,40 @@ export const CHATGPT_RENDERED_MESSAGE_TEXT_JS = `
|
|
|
235
235
|
return space ? out + " " : out;
|
|
236
236
|
};
|
|
237
237
|
const trimEnd = (value) => { let end = value.length; while (end > 0 && isSpaceChar(value[end - 1])) end -= 1; return value.slice(0, end); };
|
|
238
|
+
// The newer answer renderer sets white-space: pre-line on a paragraph,
|
|
239
|
+
// so a newline inside it is a line break on screen (measured
|
|
240
|
+
// 2026-10-28). Collapsing it like source whitespace returned "alpha beta"
|
|
241
|
+
// for an answer showing alpha and beta on two lines. Spaces and tabs
|
|
242
|
+
// still collapse; only the newlines stay.
|
|
243
|
+
const collapseKeepingLines = (value) => {
|
|
244
|
+
let out = "";
|
|
245
|
+
let space = false;
|
|
246
|
+
for (const ch of value) {
|
|
247
|
+
if (ch === NL) { while (out.endsWith(" ")) out = out.slice(0, -1); out += NL; space = false; continue; }
|
|
248
|
+
if (isSpaceChar(ch)) { space = true; continue; }
|
|
249
|
+
if (space && out && !out.endsWith(NL)) out += " ";
|
|
250
|
+
space = false;
|
|
251
|
+
out += ch;
|
|
252
|
+
}
|
|
253
|
+
return space && !out.endsWith(NL) ? out + " " : out;
|
|
254
|
+
};
|
|
255
|
+
const keepsLineBreaks = (node, inherited) => {
|
|
256
|
+
if (!node || node.nodeType !== 1) return inherited;
|
|
257
|
+
let whiteSpace = null;
|
|
258
|
+
try {
|
|
259
|
+
const view = node.ownerDocument && node.ownerDocument.defaultView;
|
|
260
|
+
if (view && typeof view.getComputedStyle === "function") whiteSpace = view.getComputedStyle(node).whiteSpace;
|
|
261
|
+
} catch (error) {
|
|
262
|
+
whiteSpace = null;
|
|
263
|
+
}
|
|
264
|
+
if (!whiteSpace) {
|
|
265
|
+
const style = (attr(node, "style") || "").toLowerCase();
|
|
266
|
+
const at = style.indexOf("white-space");
|
|
267
|
+
if (at >= 0) whiteSpace = style.slice(at).split(":")[1].split(";")[0].trim();
|
|
268
|
+
}
|
|
269
|
+
if (!whiteSpace) return inherited;
|
|
270
|
+
return whiteSpace === "pre" || whiteSpace === "pre-wrap" || whiteSpace === "pre-line" || whiteSpace === "break-spaces";
|
|
271
|
+
};
|
|
238
272
|
const isCheckbox = (node) => attr(node, "role") === "checkbox" || (tagOf(node) === "input" && attr(node, "type") === "checkbox");
|
|
239
273
|
const skipped = (node) => {
|
|
240
274
|
const tag = tagOf(node);
|
|
@@ -287,10 +321,11 @@ export const CHATGPT_RENDERED_MESSAGE_TEXT_JS = `
|
|
|
287
321
|
const link = findAll(node, (n) => tagOf(n) === "a" && (attr(n, "href") || "").startsWith("http"))[0];
|
|
288
322
|
return link ? "[" + label + "](" + attr(link, "href") + ")" : "[" + label + "]";
|
|
289
323
|
};
|
|
290
|
-
const inline = (node) => {
|
|
324
|
+
const inline = (node, keep) => {
|
|
325
|
+
const keepLines = keepsLineBreaks(node, keep === true);
|
|
291
326
|
let out = "";
|
|
292
327
|
for (const child of children(node)) {
|
|
293
|
-
const piece = inlineNode(child);
|
|
328
|
+
const piece = inlineNode(child, keepLines);
|
|
294
329
|
if (!piece) continue;
|
|
295
330
|
// A source pill sits right after its sentence; keep one space before it.
|
|
296
331
|
if (child.nodeType === 1 && (attr(child, "data-chatgpt-copy-reference") !== null || isSourceBadge(child)) && out && !isSpaceChar(out[out.length - 1])) out += " ";
|
|
@@ -298,8 +333,8 @@ export const CHATGPT_RENDERED_MESSAGE_TEXT_JS = `
|
|
|
298
333
|
}
|
|
299
334
|
return out;
|
|
300
335
|
};
|
|
301
|
-
const inlineNode = (node) => {
|
|
302
|
-
if (node.nodeType === 3) return collapse(node.textContent || "");
|
|
336
|
+
const inlineNode = (node, keep) => {
|
|
337
|
+
if (node.nodeType === 3) return keep === true ? collapseKeepingLines(node.textContent || "") : collapse(node.textContent || "");
|
|
303
338
|
if (node.nodeType !== 1 || skipped(node)) return "";
|
|
304
339
|
const tag = tagOf(node);
|
|
305
340
|
// A task list item's box (measured: button[role=checkbox][aria-checked]).
|
|
@@ -308,7 +343,7 @@ export const CHATGPT_RENDERED_MESSAGE_TEXT_JS = `
|
|
|
308
343
|
if (attr(node, "data-chatgpt-copy-reference") !== null || isSourceBadge(node)) return pill(node);
|
|
309
344
|
if (attr(node, "data-markdown-copy") === "inline-code" || tag === "code") return BT + (node.textContent || "") + BT;
|
|
310
345
|
if (tag === "br") return NL;
|
|
311
|
-
const inner = inline(node);
|
|
346
|
+
const inner = inline(node, keep);
|
|
312
347
|
// The newer renderer marks bold as span[data-d-default-strong].
|
|
313
348
|
if (tag === "strong" || tag === "b" || attr(node, "data-d-default-strong") !== null) return inner.trim() ? "**" + inner.trim() + "**" : "";
|
|
314
349
|
if (tag === "em" || tag === "i") return inner.trim() ? "*" + inner.trim() + "*" : "";
|
|
@@ -354,7 +389,7 @@ export const CHATGPT_RENDERED_MESSAGE_TEXT_JS = `
|
|
|
354
389
|
const line = (cells) => "| " + cells.join(" | ") + " |";
|
|
355
390
|
return [line(rows[0]), line(rows[0].map(() => "---")), ...rows.slice(1).map(line)].join(NL);
|
|
356
391
|
};
|
|
357
|
-
const list = (node, depth) => {
|
|
392
|
+
const list = (node, depth, keep) => {
|
|
358
393
|
const ordered = tagOf(node) === "ol";
|
|
359
394
|
let number = parseInt(attr(node, "start") || "1", 10);
|
|
360
395
|
if (!Number.isFinite(number)) number = 1;
|
|
@@ -367,19 +402,20 @@ export const CHATGPT_RENDERED_MESSAGE_TEXT_JS = `
|
|
|
367
402
|
const words = [];
|
|
368
403
|
const nested = [];
|
|
369
404
|
let buffer = "";
|
|
405
|
+
const keepItemLines = keepsLineBreaks(item, keep === true);
|
|
370
406
|
for (const child of children(item)) {
|
|
371
407
|
const tag = child.nodeType === 1 ? tagOf(child) : "";
|
|
372
|
-
if (tag === "ul" || tag === "ol") { nested.push(list(child, depth + 1)); continue; }
|
|
373
|
-
if (isBlock(child)) { if (buffer.trim()) words.push(buffer.trim()); buffer = ""; const b = block(child, depth + 1); if (b) words.push(b); continue; }
|
|
374
|
-
buffer += inlineNode(child);
|
|
408
|
+
if (tag === "ul" || tag === "ol") { nested.push(list(child, depth + 1, keepItemLines)); continue; }
|
|
409
|
+
if (isBlock(child)) { if (buffer.trim()) words.push(buffer.trim()); buffer = ""; const b = block(child, depth + 1, keepItemLines); if (b) words.push(b); continue; }
|
|
410
|
+
buffer += inlineNode(child, keepItemLines);
|
|
375
411
|
}
|
|
376
412
|
if (buffer.trim()) words.push(buffer.trim());
|
|
377
413
|
items.push(indent + marker + " " + words.join(" ") + (nested.length ? NL + nested.join(NL) : ""));
|
|
378
414
|
}
|
|
379
415
|
return items.join(NL);
|
|
380
416
|
};
|
|
381
|
-
const block = (node, depth) => {
|
|
382
|
-
if (node.nodeType === 3) return collapse(node.textContent || "").trim();
|
|
417
|
+
const block = (node, depth, keep) => {
|
|
418
|
+
if (node.nodeType === 3) return (keep === true ? collapseKeepingLines(node.textContent || "") : collapse(node.textContent || "")).trim();
|
|
383
419
|
if (node.nodeType !== 1 || skipped(node)) return "";
|
|
384
420
|
const tag = tagOf(node);
|
|
385
421
|
if (isMath(node)) return isDisplayMath(node) ? "$$" + NL + mathSource(node) + NL + "$$" : "$" + mathSource(node) + "$";
|
|
@@ -387,19 +423,20 @@ export const CHATGPT_RENDERED_MESSAGE_TEXT_JS = `
|
|
|
387
423
|
if (isMermaidPreview(node)) return mermaidBlock(node);
|
|
388
424
|
if (tag === "table") return table(node);
|
|
389
425
|
if (/^h[1-6]$/.test(tag)) return "#".repeat(Number(tag[1])) + " " + inline(node).trim();
|
|
390
|
-
if (tag === "ul" || tag === "ol") return list(node, depth);
|
|
426
|
+
if (tag === "ul" || tag === "ol") return list(node, depth, keepsLineBreaks(node, keep === true));
|
|
391
427
|
if (tag === "hr") return "---";
|
|
392
|
-
if (tag === "blockquote") return blocks(node, depth).split(NL).map((line) => "> " + line).join(NL);
|
|
393
|
-
if (tag === "p") return inline(node).trim();
|
|
394
|
-
return blocks(node, depth);
|
|
428
|
+
if (tag === "blockquote") return blocks(node, depth, keep).split(NL).map((line) => "> " + line).join(NL);
|
|
429
|
+
if (tag === "p") return inline(node, keep).trim();
|
|
430
|
+
return blocks(node, depth, keep);
|
|
395
431
|
};
|
|
396
|
-
const blocks = (node, depth) => {
|
|
432
|
+
const blocks = (node, depth, keep) => {
|
|
433
|
+
const keepLines = keepsLineBreaks(node, keep === true);
|
|
397
434
|
const parts = [];
|
|
398
435
|
let buffer = "";
|
|
399
436
|
const flush = () => { if (buffer.trim()) parts.push(buffer.trim()); buffer = ""; };
|
|
400
437
|
for (const child of children(node)) {
|
|
401
|
-
if (isBlock(child)) { flush(); const b = block(child, depth); if (b) parts.push(b); continue; }
|
|
402
|
-
buffer += inlineNode(child);
|
|
438
|
+
if (isBlock(child)) { flush(); const b = block(child, depth, keepLines); if (b) parts.push(b); continue; }
|
|
439
|
+
buffer += inlineNode(child, keepLines);
|
|
403
440
|
}
|
|
404
441
|
flush();
|
|
405
442
|
return parts.join(NL + NL);
|
|
@@ -1337,13 +1374,27 @@ export function chatGptBusyBlocker(state) {
|
|
|
1337
1374
|
// away from ready into a full send budget spent on "still generating".
|
|
1338
1375
|
if (!state.generating || state.awaitingResponseChoice === true)
|
|
1339
1376
|
return undefined;
|
|
1377
|
+
// The thread on screen is the one generating. It goes into the advice only:
|
|
1378
|
+
// `thread` on a blocker means where THIS send's prompt landed, and it landed
|
|
1379
|
+
// nowhere.
|
|
1380
|
+
let generatingThread = "<thread-url>";
|
|
1381
|
+
try {
|
|
1382
|
+
if (state.url)
|
|
1383
|
+
generatingThread = normalizeChatGptThreadUrl(state.url);
|
|
1384
|
+
}
|
|
1385
|
+
catch {
|
|
1386
|
+
// Not a conversation page; keep the placeholder.
|
|
1387
|
+
}
|
|
1340
1388
|
return {
|
|
1341
1389
|
code: "response_in_progress",
|
|
1342
1390
|
message: "ChatGPT is still generating a previous response in this thread.",
|
|
1343
1391
|
retryable: true,
|
|
1392
|
+
// It used to end "A new topic can go to a new chat instead", which a send
|
|
1393
|
+
// already started with --new-chat had just done: every send waits for the
|
|
1394
|
+
// shared tab, since leaving a thread mid-answer could lose that answer.
|
|
1344
1395
|
next_step: "Wait for it to finish and retry (pass --busy-wait-ms to queue behind it longer). " +
|
|
1345
|
-
"
|
|
1346
|
-
|
|
1396
|
+
"A new chat waits as well: the browser tab is shared, and leaving a thread mid-answer could lose that answer. " +
|
|
1397
|
+
`If that in-flight answer is the one you need, fetch it once it settles: \`prodex pro browser recover --target-url ${generatingThread}\`.`
|
|
1347
1398
|
};
|
|
1348
1399
|
}
|
|
1349
1400
|
/**
|
package/dist/cli-pro.js
CHANGED
|
@@ -11,7 +11,7 @@ import { listRawResultsForInspection, listTasksForInspection } from "./cli-ledge
|
|
|
11
11
|
import { formatBrowserDefaults, redactServerUrl } from "./cli-server.js";
|
|
12
12
|
import { errorMessage, firstLine, formatBlockedConsultRecordedMessage, formatProLatestCommand, formatBrowserCheckCommand, formatBrowserLoginCommand, formatBrowserSmokeCommand, formatBrowserTargetAskCommand, formatInitCommand, formatSetupCommand, isMissingFileError, computeSendPacingWaitMs, isUntrustedResultError, resolveMinSendIntervalMs, sourceAwareBrowserBlocker, sourceAwareBrowserNextStep, sourceAwareResultError, sourceAwareResultMessage, sourceAwareSetupMessage } from "./cli-shared.js";
|
|
13
13
|
import { getTokenExpiryStatus, loadBrowserDefaults, loadLocalConfig } from "./config.js";
|
|
14
|
-
import { withBrowserSendLock } from "./browser-send-lock.js";
|
|
14
|
+
import { sendPacingMarkerPath, withBrowserSendLock } from "./browser-send-lock.js";
|
|
15
15
|
import { blockerCause, buildBlockerReport, CATCH_ALL_CODES } from "./blocker-report.js";
|
|
16
16
|
import { isChatGptConversationUrl, projectIdFromSidebar, resolveContinuationThread } from "./continue-thread.js";
|
|
17
17
|
import { FollowupApprovalRequired, reserveFollowup, resolveMaxAutoFollowups } from "./followup-budget.js";
|
|
@@ -842,10 +842,13 @@ export async function runProCommand(rest, io, runCliFn) {
|
|
|
842
842
|
if (!target.ok)
|
|
843
843
|
throw new Error(target.reason);
|
|
844
844
|
// Deleting a project is not undoable from here, so the default is a
|
|
845
|
-
// preview: say exactly what would go, and delete nothing.
|
|
845
|
+
// preview: say exactly what would go, and delete nothing. The browser
|
|
846
|
+
// layer does not automate deletion at all, so the preview must not
|
|
847
|
+
// promise that --confirm-delete would carry it out - it used to, and
|
|
848
|
+
// the confirmed run then failed as unsupported.
|
|
846
849
|
if (!browserArgs.includes("--confirm-delete")) {
|
|
847
|
-
io.stdout(`
|
|
848
|
-
io.stdout("Nothing was deleted.
|
|
850
|
+
io.stdout(`Deleting project "${target.name}" (${target.id}) would remove everything filed under it.`);
|
|
851
|
+
io.stdout("Nothing was deleted. prodex does not delete projects itself: delete it from its menu in the ChatGPT UI.");
|
|
849
852
|
return 0;
|
|
850
853
|
}
|
|
851
854
|
await deleteChatGptProject({
|
|
@@ -1109,11 +1112,14 @@ function legacyBrowserSubcommandReplacement(subcommand) {
|
|
|
1109
1112
|
return "check";
|
|
1110
1113
|
}
|
|
1111
1114
|
// File marker + human-pacing gate for visible-browser sends. Reads the previous
|
|
1112
|
-
// send's start time from
|
|
1113
|
-
// minimum interval (auto-throttle, not an error), then stamps
|
|
1114
|
-
|
|
1115
|
+
// send's start time from the machine-wide marker beside the send lock, waits
|
|
1116
|
+
// out any remaining minimum interval (auto-throttle, not an error), then stamps
|
|
1117
|
+
// the new send. Runs while the send lock is held: run before it, a send queued
|
|
1118
|
+
// behind another posted the moment the lock freed, and the start it stamped was
|
|
1119
|
+
// when it began waiting rather than when it sent.
|
|
1120
|
+
async function enforceVisibleBrowserSendPacing(stderr) {
|
|
1115
1121
|
const intervalMs = resolveMinSendIntervalMs();
|
|
1116
|
-
const markerPath =
|
|
1122
|
+
const markerPath = sendPacingMarkerPath();
|
|
1117
1123
|
let lastSendAtMs;
|
|
1118
1124
|
try {
|
|
1119
1125
|
const parsed = Date.parse((await readFile(markerPath, "utf8")).trim());
|
|
@@ -1308,6 +1314,7 @@ export async function runAskProCommand(rest, io, beforeSend) {
|
|
|
1308
1314
|
// Scope by the project this send would have used, so a follow-up cannot
|
|
1309
1315
|
// land in another project's conversation.
|
|
1310
1316
|
const continuationProject = explicitProject ?? (suppressProject ? undefined : browserDefaults?.project);
|
|
1317
|
+
assertSavedProjectNotBlank(continuationProject);
|
|
1311
1318
|
// The name alone cannot identify a project written in another script -
|
|
1312
1319
|
// measured, two of this account's projects slug to nothing - so the id
|
|
1313
1320
|
// is read off the live sidebar. A sidebar that cannot be read leaves
|
|
@@ -1414,6 +1421,7 @@ export async function runAskProCommand(rest, io, beforeSend) {
|
|
|
1414
1421
|
(normalizedTargetUrl || selectionProjectNew !== undefined || suppressProject || temporary
|
|
1415
1422
|
? undefined
|
|
1416
1423
|
: browserDefaults?.project);
|
|
1424
|
+
assertSavedProjectNotBlank(selectionProject);
|
|
1417
1425
|
const selectionProMode = selectionAxes.proMode;
|
|
1418
1426
|
const selectionEffort = selectionAxes.effort;
|
|
1419
1427
|
const continuationScopeProject = continuedFromTaskId
|
|
@@ -1464,7 +1472,6 @@ export async function runAskProCommand(rest, io, beforeSend) {
|
|
|
1464
1472
|
...(normalizedTargetUrl ? { thread: normalizedTargetUrl } : {}),
|
|
1465
1473
|
...(continuedFromTaskId ? { continuedFrom: continuedFromTaskId } : {})
|
|
1466
1474
|
});
|
|
1467
|
-
await enforceVisibleBrowserSendPacing(targetCwd, io.stderr);
|
|
1468
1475
|
const browserCommandOptions = {
|
|
1469
1476
|
cwd: targetCwd,
|
|
1470
1477
|
port: parsedAskPro.optionArgs.includes("--port") ? browserPort : undefined
|
|
@@ -1521,28 +1528,31 @@ export async function runAskProCommand(rest, io, beforeSend) {
|
|
|
1521
1528
|
// fail-fast default made pro_consult die instantly with advice the
|
|
1522
1529
|
// caller had no way to follow; --busy-wait-ms 0 opts back into failing
|
|
1523
1530
|
// fast. A dead holder is still reaped immediately.
|
|
1524
|
-
const sendOnce = () => withBrowserSendLock(busyWaitMs ?? browserTimeoutMs ?? 0, (detail) => io.stderr(`progress: ${detail}`), () =>
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1531
|
+
const sendOnce = () => withBrowserSendLock(busyWaitMs ?? browserTimeoutMs ?? 0, (detail) => io.stderr(`progress: ${detail}`), async () => {
|
|
1532
|
+
await enforceVisibleBrowserSendPacing(io.stderr);
|
|
1533
|
+
return sendChatGptPrompt({
|
|
1534
|
+
port: browserPort,
|
|
1535
|
+
prompt: bundle.sendText,
|
|
1536
|
+
targetUrl: normalizedTargetUrl,
|
|
1537
|
+
// Continuations and explicit TUI picks navigate while this lock is
|
|
1538
|
+
// held. Direct CLI targets keep their existing verification-only semantics.
|
|
1539
|
+
...(continuedFromTaskId || (io.navigateInteractiveTarget === true && normalizedTargetUrl) ? { navigateToTargetUrl: true } : {}),
|
|
1540
|
+
timeoutMs: browserTimeoutMs,
|
|
1541
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
1542
|
+
...(tools.length > 0 ? { tools } : {}),
|
|
1543
|
+
...(newChat ? { newChat: true } : {}),
|
|
1544
|
+
...(temporary ? { temporary: true } : {}),
|
|
1545
|
+
diagnosticsCwd: targetCwd,
|
|
1546
|
+
...(busyWaitMs !== undefined ? { busyWaitMs } : {}),
|
|
1547
|
+
project: selectionProject,
|
|
1548
|
+
projectNew: selectionProjectNew,
|
|
1549
|
+
model: selectionModel,
|
|
1550
|
+
proMode: selectionProMode,
|
|
1551
|
+
effort: selectionEffort,
|
|
1552
|
+
...(allowSelectionFallback ? { allowSelectionFallback: true } : {}),
|
|
1553
|
+
onProgress: createBrowserSendProgressPrinter(io.stderr)
|
|
1554
|
+
});
|
|
1555
|
+
});
|
|
1546
1556
|
// One-command recovery: interactive terminals (or explicit --auto-login)
|
|
1547
1557
|
// launch the dedicated browser and retry once when no browser runs.
|
|
1548
1558
|
// Scripts and agents keep the plain blocker unless they opt in.
|
|
@@ -2659,6 +2669,18 @@ export function resolveSelectionAxes(input) {
|
|
|
2659
2669
|
...(effort !== undefined ? { effort } : {})
|
|
2660
2670
|
};
|
|
2661
2671
|
}
|
|
2672
|
+
/**
|
|
2673
|
+
* A blank project here can only be the repo's saved default: explicit flags are
|
|
2674
|
+
* refused as blank before this. setup accepted one until 0.40.43, and a send
|
|
2675
|
+
* then searched the shared browser's sidebar for a project with no name before
|
|
2676
|
+
* failing as project_not_found (measured 2026-10-28).
|
|
2677
|
+
*/
|
|
2678
|
+
function assertSavedProjectNotBlank(project) {
|
|
2679
|
+
if (project !== undefined && !project.trim()) {
|
|
2680
|
+
throw new Error("The default project saved for this repo in .bridge/config.local.json is blank, so there is no project to send into. " +
|
|
2681
|
+
'Clear it with `prodex setup --clear-project` (or pin one with `prodex setup --project "name"`), or pass --project or --no-project for this send.');
|
|
2682
|
+
}
|
|
2683
|
+
}
|
|
2662
2684
|
/**
|
|
2663
2685
|
* Scrub project names out of text that gets persisted.
|
|
2664
2686
|
*
|
|
@@ -2671,9 +2693,16 @@ export function resolveSelectionAxes(input) {
|
|
|
2671
2693
|
export function redactProjectNames(text, names) {
|
|
2672
2694
|
let redacted = text;
|
|
2673
2695
|
// Longest first: a project named "Notes" inside "Notes Archive" would
|
|
2674
|
-
// otherwise leave "<project> Archive" behind.
|
|
2675
|
-
|
|
2676
|
-
|
|
2696
|
+
// otherwise leave "<project> Archive" behind. A blank name names nothing;
|
|
2697
|
+
// replacing it turned every space of a record into "<project>".
|
|
2698
|
+
for (const name of [...names].filter((name) => Boolean(name?.trim())).sort((a, b) => b.length - a.length)) {
|
|
2699
|
+
// As a word: a plain substring replacement read "Go" inside "Google". The
|
|
2700
|
+
// boundary applies only at an ASCII word character, since a name in a
|
|
2701
|
+
// script like Korean takes its particles directly ("회의록에서").
|
|
2702
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2703
|
+
const before = /^[A-Za-z0-9_]/.test(name) ? "(?<![A-Za-z0-9_])" : "";
|
|
2704
|
+
const after = /[A-Za-z0-9_]$/.test(name) ? "(?![A-Za-z0-9_])" : "";
|
|
2705
|
+
redacted = redacted.replace(new RegExp(`${before}${escaped}${after}`, "g"), "<project>");
|
|
2677
2706
|
}
|
|
2678
2707
|
return redacted;
|
|
2679
2708
|
}
|
package/dist/config.js
CHANGED
|
@@ -94,6 +94,11 @@ export function normalizeLoopbackHttpHost(host) {
|
|
|
94
94
|
const isIpv4Loopback = isIP(normalized) === 4 && normalized.startsWith("127.");
|
|
95
95
|
if (isLocalhost || isIpv4Loopback)
|
|
96
96
|
return normalized;
|
|
97
|
+
// ::1 IS loopback; it was refused as if it were not. The listener and every
|
|
98
|
+
// URL prodex prints are IPv4-shaped, so say that rather than "loopback-only".
|
|
99
|
+
if (normalized.replace(/^\[|\]$/g, "") === "::1") {
|
|
100
|
+
throw new Error("HTTP MCP host ::1 (IPv6 loopback) is not supported: prodex prints and serves IPv4 URLs. Use --host 127.0.0.1 or localhost.");
|
|
101
|
+
}
|
|
97
102
|
throw new Error("HTTP MCP host must be loopback-only, such as 127.0.0.1 or localhost. Keep prodex local and put your own tunnel in front of it when needed.");
|
|
98
103
|
}
|
|
99
104
|
export function assertLoopbackHttpHost(host) {
|
package/dist/store.js
CHANGED
|
@@ -517,6 +517,13 @@ export class BridgeStore {
|
|
|
517
517
|
// fields; throws if the session does not exist.
|
|
518
518
|
async cancelSession(sessionId) {
|
|
519
519
|
const existing = await this.getSession(sessionId);
|
|
520
|
+
// Cancel marks a session left running (or a preview) as abandoned. A
|
|
521
|
+
// finished one was rewritten too, so a consult that had answered read
|
|
522
|
+
// "cancelled" here while its task said done.
|
|
523
|
+
if (existing.status === "done" || existing.status === "blocked") {
|
|
524
|
+
const why = existing.blocker?.code === "cancelled" ? "it was cancelled before" : "its outcome is recorded";
|
|
525
|
+
throw new Error(`Session ${existing.id} is already ${existing.status}; ${why}, so there is nothing to cancel.`);
|
|
526
|
+
}
|
|
520
527
|
return this.writeSession({
|
|
521
528
|
id: existing.id,
|
|
522
529
|
direction: existing.direction,
|
package/dist/tui.js
CHANGED
|
@@ -186,11 +186,82 @@ const CURSOR_BAR = "▌";
|
|
|
186
186
|
function paint(text, code, color) {
|
|
187
187
|
return color ? `${code}${text}${RESET}` : text;
|
|
188
188
|
}
|
|
189
|
-
|
|
189
|
+
// Color codes take no columns, and must be carried through a cut whole.
|
|
190
|
+
const SGR_PATTERN = /^\u001b\[[0-9;]*m/;
|
|
191
|
+
/**
|
|
192
|
+
* Terminal columns a character takes: two for East Asian wide characters
|
|
193
|
+
* (Hangul, CJK, full-width forms) and the common emoji, none for combining and
|
|
194
|
+
* variation marks, one otherwise. Counting UTF-16 units instead put a Korean
|
|
195
|
+
* conversation title at twice its allowance, so it wrapped into the next row.
|
|
196
|
+
*/
|
|
197
|
+
function charColumns(codePoint) {
|
|
198
|
+
if (codePoint < 0x20 || (codePoint >= 0x7f && codePoint < 0xa0))
|
|
199
|
+
return 0;
|
|
200
|
+
if ((codePoint >= 0x300 && codePoint <= 0x36f) || codePoint === 0x200b || codePoint === 0x200d || (codePoint >= 0xfe00 && codePoint <= 0xfe0f)) {
|
|
201
|
+
return 0;
|
|
202
|
+
}
|
|
203
|
+
if ((codePoint >= 0x1100 && codePoint <= 0x115f) ||
|
|
204
|
+
(codePoint >= 0x2e80 && codePoint <= 0xa4cf && codePoint !== 0x303f) ||
|
|
205
|
+
(codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
|
|
206
|
+
(codePoint >= 0xf900 && codePoint <= 0xfaff) ||
|
|
207
|
+
(codePoint >= 0xfe30 && codePoint <= 0xfe4f) ||
|
|
208
|
+
(codePoint >= 0xff00 && codePoint <= 0xff60) ||
|
|
209
|
+
(codePoint >= 0xffe0 && codePoint <= 0xffe6) ||
|
|
210
|
+
(codePoint >= 0x1f300 && codePoint <= 0x1faff) ||
|
|
211
|
+
(codePoint >= 0x20000 && codePoint <= 0x3fffd)) {
|
|
212
|
+
return 2;
|
|
213
|
+
}
|
|
214
|
+
return 1;
|
|
215
|
+
}
|
|
216
|
+
/** Columns a string takes on screen, ignoring color codes. */
|
|
217
|
+
export function displayWidth(text) {
|
|
218
|
+
let columns = 0;
|
|
219
|
+
for (let index = 0; index < text.length;) {
|
|
220
|
+
const sgr = SGR_PATTERN.exec(text.slice(index));
|
|
221
|
+
if (sgr) {
|
|
222
|
+
index += sgr[0].length;
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
const codePoint = text.codePointAt(index);
|
|
226
|
+
columns += charColumns(codePoint);
|
|
227
|
+
index += codePoint > 0xffff ? 2 : 1;
|
|
228
|
+
}
|
|
229
|
+
return columns;
|
|
230
|
+
}
|
|
231
|
+
/** Pad with spaces to a number of columns, not of UTF-16 units. */
|
|
232
|
+
export function padEndToWidth(text, width) {
|
|
233
|
+
return text + " ".repeat(Math.max(0, width - displayWidth(text)));
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Cut a line to the terminal rather than letting it wrap into a second row.
|
|
237
|
+
* Counts columns, keeps color codes whole, never splits a character, and closes
|
|
238
|
+
* a color the cut fell inside: a cut that dropped the reset left the rows
|
|
239
|
+
* below in the label's color.
|
|
240
|
+
*/
|
|
190
241
|
export function truncateToWidth(text, width) {
|
|
191
|
-
if (width <= 0 || text
|
|
242
|
+
if (width <= 0 || displayWidth(text) <= width)
|
|
192
243
|
return text;
|
|
193
|
-
|
|
244
|
+
const budget = width <= 3 ? width : width - 3;
|
|
245
|
+
let out = "";
|
|
246
|
+
let columns = 0;
|
|
247
|
+
let colored = false;
|
|
248
|
+
for (let index = 0; index < text.length;) {
|
|
249
|
+
const sgr = SGR_PATTERN.exec(text.slice(index));
|
|
250
|
+
if (sgr) {
|
|
251
|
+
out += sgr[0];
|
|
252
|
+
colored = sgr[0] !== RESET;
|
|
253
|
+
index += sgr[0].length;
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
const codePoint = text.codePointAt(index);
|
|
257
|
+
const step = charColumns(codePoint);
|
|
258
|
+
if (columns + step > budget)
|
|
259
|
+
break;
|
|
260
|
+
out += String.fromCodePoint(codePoint);
|
|
261
|
+
columns += step;
|
|
262
|
+
index += codePoint > 0xffff ? 2 : 1;
|
|
263
|
+
}
|
|
264
|
+
return `${out}${width <= 3 ? "" : "..."}${colored ? RESET : ""}`;
|
|
194
265
|
}
|
|
195
266
|
export function renderSelectList(input) {
|
|
196
267
|
const color = input.color ?? true;
|
|
@@ -202,7 +273,7 @@ export function renderSelectList(input) {
|
|
|
202
273
|
const lines = [`${paint(input.title, BOLD, color)}${step}`, ""];
|
|
203
274
|
// Hints line up in their own column; ragged hints read as noise next to the
|
|
204
275
|
// labels they belong to.
|
|
205
|
-
const labelWidth = Math.max(...input.options.map((option) => option.label
|
|
276
|
+
const labelWidth = Math.max(...input.options.map((option) => displayWidth(option.label)), 0);
|
|
206
277
|
input.options.forEach((option, index) => {
|
|
207
278
|
const onCursor = index === input.cursor;
|
|
208
279
|
// A left bar reads as a cursor even once the row is colored, where a ">"
|
|
@@ -213,9 +284,9 @@ export function renderSelectList(input) {
|
|
|
213
284
|
const ordinal = paint(`${index + 1}`, onCursor ? ACCENT : DIM, color);
|
|
214
285
|
const box = input.multi ? (selected.has(index) ? "[x] " : "[ ] ") : "";
|
|
215
286
|
const label = onCursor ? paint(option.label, BOLD, color) : option.label;
|
|
216
|
-
const gap = " ".repeat(Math.max(0, labelWidth - option.label
|
|
287
|
+
const gap = " ".repeat(Math.max(0, labelWidth - displayWidth(option.label)));
|
|
217
288
|
const hint = option.hint ? `${gap} ${paint(option.hint, DIM, color)}` : "";
|
|
218
|
-
lines.push(truncateToWidth(` ${bar} ${ordinal} ${box}${label}${hint}`, width
|
|
289
|
+
lines.push(truncateToWidth(` ${bar} ${ordinal} ${box}${label}${hint}`, width));
|
|
219
290
|
});
|
|
220
291
|
if (input.footer)
|
|
221
292
|
lines.push("", paint(input.footer, DIM, color));
|
|
@@ -234,8 +305,8 @@ export function renderContextPanel(rows, options = {}) {
|
|
|
234
305
|
const texts = rows.map((row) => ` ${row.label.padEnd(labelWidth)} ${row.value} `);
|
|
235
306
|
// Size the frame to its contents, capped by the terminal. A box stretched to
|
|
236
307
|
// the full width is mostly empty space with a border around it.
|
|
237
|
-
const inner = Math.min(Math.max(...texts.map((text) => text
|
|
238
|
-
const body = texts.map((text) => `│${truncateToWidth(text, inner)
|
|
308
|
+
const inner = Math.min(Math.max(...texts.map((text) => displayWidth(text)), 0), Math.max(20, (options.width ?? 72) - 2));
|
|
309
|
+
const body = texts.map((text) => `│${padEndToWidth(truncateToWidth(text, inner), inner)}│`);
|
|
239
310
|
const top = `╭${"─".repeat(inner)}╮`;
|
|
240
311
|
const bottom = `╰${"─".repeat(inner)}╯`;
|
|
241
312
|
return [top, ...body, bottom].map((line) => (color ? paint(line, DIM, color) : line)).join("\n");
|
package/docs/releasing.md
CHANGED
|
@@ -35,6 +35,47 @@ The release adds a guarded, opt-in transition and more accurate blocker reportin
|
|
|
35
35
|
**Open**
|
|
36
36
|
- A long-running headless browser ends up behind Cloudflare's check (also noted on WSL, where the Xvfb display is used instead). On macOS there is no virtual display, and a minimized window counts as hidden, so the remaining no-headless option there is a window kept behind others. Changing the headless preference is a design decision that was not made here.
|
|
37
37
|
|
|
38
|
+
## 0.40.45 Release Record
|
|
39
|
+
|
|
40
|
+
Release commit `5e5f965` on `main` (cut after PRs #59 and #63-#68, last merge `52243f4`); tag `v0.40.45`. A batch released on the user's go-ahead: machine-wide send pacing (#59), accurate advice for a busy tab (#63), word-bounded project-name redaction (#64), the IPv6 loopback message (#65), no cancelling of finished sessions (#66), a blank saved default project stopped before the browser (#67), and the project-delete preview (#68).
|
|
41
|
+
|
|
42
|
+
**Publishing**
|
|
43
|
+
- `npm run release:verify` passed on the release commit after a build (nothing else was run on the machine meanwhile, after 0.40.44's load-related flake).
|
|
44
|
+
- `publish.yml` run 40634873154 passed all six platforms, then npm publish with SLSA v1 provenance and the GitHub Release, which lists the seven entries from `CHANGELOG.md`.
|
|
45
|
+
|
|
46
|
+
**Installed**
|
|
47
|
+
- WSL, M3, Mac mini: `prodex --version` reports 0.40.45. Running services were not restarted: no host stdio MCP servers on WSL, 1 on M3, none on the Mac mini.
|
|
48
|
+
- The maintainer watchdog checkout was pulled to `5e5f965` and rebuilt.
|
|
49
|
+
|
|
50
|
+
**Container**
|
|
51
|
+
- `container/redesign-patches` `7f40247` ports #59, #60 and #63-#68 (full suite, 165 files and 2112 tests, and `release:verify` pass on it). #62 changes main's CI only and is not ported.
|
|
52
|
+
- The images were still not rebuilt: `docker compose build` is refused by the agent's permission classifier on WSL and over ssh on M3. The running WSL container is the #49-#58 port and the M3 container the 0.40.42 port.
|
|
53
|
+
|
|
54
|
+
**Verified live on installed 0.40.45 (WSL, scratch repo, unreachable CDP port)**
|
|
55
|
+
- `setup --host ::1`: "HTTP MCP host ::1 (IPv6 loopback) is not supported ... Use --host 127.0.0.1 or localhost."
|
|
56
|
+
- A repo whose config holds project " ": the send stops with "The default project saved for this repo ... is blank" before any browser step; `--no-project` goes on to the browser.
|
|
57
|
+
- The send-pacing marker was written beside the send lock (`last-browser-send`), not in the repo's `.bridge`.
|
|
58
|
+
- `sessions cancel` on a blocked session: "is already blocked; its outcome is recorded, so there is nothing to cancel."
|
|
59
|
+
|
|
60
|
+
## 0.40.44 Release Record
|
|
61
|
+
|
|
62
|
+
Release commit `060d71c` on `main` (cut after PRs #60-#62, last merge `95c5ddc`); tag `v0.40.44`. Released promptly because of #61: 0.40.43's stale-lock check (#54) derived a holder's wall-clock start from `/proc/stat` btime, which drifts on WSL2 (measured 2026-10-27: 3 to 4 seconds every 30 seconds, 30 minutes over a day), so a send a few minutes into another session's consult freed the live lock and drove the same tab. #60 (picker paths with escaped spaces) went out with it; #62 changes CI only. #59 (machine-wide send pacing) was not ready and is not in this release.
|
|
63
|
+
|
|
64
|
+
**Publishing**
|
|
65
|
+
- The first local `release:verify` failed on `tests/browser-slow-vs-dead.test.ts` "tiny timeout on a closed port" while other tests loaded the machine; it passed alone (8/8), the uncommitted release edits were discarded and the release rerun passed `release:verify`.
|
|
66
|
+
- `publish.yml` run 40618706024 passed all six platforms, then npm publish with SLSA v1 provenance and the GitHub Release, which lists the two entries from `CHANGELOG.md`.
|
|
67
|
+
|
|
68
|
+
**Installed**
|
|
69
|
+
- WSL, M3, Mac mini: `prodex --version` reports 0.40.44. Running services were not restarted: no host stdio MCP servers on WSL, 1 on M3, none on the Mac mini.
|
|
70
|
+
- The maintainer watchdog checkout was pulled to `060d71c` and rebuilt.
|
|
71
|
+
|
|
72
|
+
**Container**
|
|
73
|
+
- `container/redesign-patches` `1258052` ports #61 (tsc, build, lock/send/follow-up tests and `release:verify` pass on it).
|
|
74
|
+
- The images were not rebuilt: the agent's permission classifier refused the `docker compose build` on WSL and the build over ssh on M3. The WSL container still runs image `1d48d753fbf7` (the #49-#58 port, with the drifting check) and the M3 container the 0.40.42 port, until someone with access rebuilds them.
|
|
75
|
+
|
|
76
|
+
**Verified live on installed 0.40.44 (WSL)**
|
|
77
|
+
- A live `sleep` holding a lock that records its start ticks, with `started_at` set 40 minutes back to stand in for a clock step: the send waited ("another prodex send holds the browser"); installed 0.40.43 had reaped the same lock and sent.
|
|
78
|
+
|
|
38
79
|
## 0.40.43 Release Record
|
|
39
80
|
|
|
40
81
|
Release commit `834e3e2` on `main` (cut after PRs #49-#58, last merge `a6bbf85`); tag `v0.40.43`. Ten fixes, including two that block or corrupt work: a send lock left by a dead send whose pid was reused blocked every later send (#54), and two processes opening a fresh repo could fail one with a false "receipt-key.local is corrupt" (#58).
|