@youdie006/prodex 0.40.3 → 0.40.4

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.
@@ -503,6 +503,17 @@ export function isUsableChatGptAnswer(answer) {
503
503
  return false;
504
504
  return true;
505
505
  }
506
+ /**
507
+ * What comes back when the turn finished and wrote no text.
508
+ *
509
+ * Measured with `--tool create-image`: the image was generated and rendered,
510
+ * the assistant message in the transcript came back finished with an empty
511
+ * part (the image lives in a separate tool message), and prodex waited out the
512
+ * whole budget before reporting a timeout for a result that was already there.
513
+ * The thread URL travels with every answer, so saying so and pointing at it
514
+ * beats six minutes of silence.
515
+ */
516
+ export const CHATGPT_NON_TEXT_ANSWER_NOTE = "[no text answer] ChatGPT finished this turn without writing any text - an image or another non-text result. Open the thread to see it.";
506
517
  /**
507
518
  * Who decides the answer is finished: the transcript, when it can be read.
508
519
  *
@@ -520,9 +531,16 @@ export function classifyTranscriptRead(state, sentPrompt) {
520
531
  return "unavailable";
521
532
  if (state.ok)
522
533
  return "answer";
523
- return state.reason === "answer_not_finished" || state.reason === "no_assistant_message" || state.reason === "answer_empty"
524
- ? "pending"
525
- : "unavailable";
534
+ // A turn that FINISHED with no text is not a turn still being written. The
535
+ // transcript only reports answer_empty after checking the turn ended, and
536
+ // calling it "pending" is what made an image request wait out its whole
537
+ // budget: measured, `--tool create-image` produced the image, its assistant
538
+ // message came back finished with an empty part, and the send spent six and
539
+ // a half minutes "stabilizing" before reporting a timeout for an answer that
540
+ // was sitting in the thread.
541
+ if (state.reason === "answer_empty")
542
+ return "no_text";
543
+ return state.reason === "answer_not_finished" || state.reason === "no_assistant_message" ? "pending" : "unavailable";
526
544
  }
527
545
  /**
528
546
  * Should prodex drag the tab back to the thread it pinned?
@@ -540,8 +558,11 @@ export function shouldRecoverThreadNavigation(args) {
540
558
  return false;
541
559
  if (!conversationIdFromThreadUrl(pinnedThreadUrl))
542
560
  return false;
543
- if (lastTranscriptClassification === "pending" || lastTranscriptClassification === "answer")
561
+ if (lastTranscriptClassification === "pending" ||
562
+ lastTranscriptClassification === "answer" ||
563
+ lastTranscriptClassification === "no_text") {
544
564
  return false;
565
+ }
545
566
  return !chatGptUrlsReferToSameTarget(currentUrl, pinnedThreadUrl);
546
567
  }
547
568
  export function hasFreshChatGptAnswer(previousAssistantMessageCount, state) {
@@ -1360,6 +1381,49 @@ async function dispatchEscapeKey(cdp) {
1360
1381
  * stamp is what proves the old document is gone; the URL alone can be read off
1361
1382
  * the very page we are trying to leave.
1362
1383
  */
1384
+ /**
1385
+ * Put the tab on a specific conversation, for a thread prodex itself resolved.
1386
+ */
1387
+ async function openChatGptThread(cdp, url) {
1388
+ const conversationId = conversationIdFromThreadUrl(url);
1389
+ if (!conversationId)
1390
+ throw new Error(`Not a ChatGPT conversation URL: ${url}`);
1391
+ await cdp.evaluate(`location.assign(${JSON.stringify(url)})`);
1392
+ const deadline = Date.now() + RELOAD_SETTLE_TIMEOUT_MS;
1393
+ while (Date.now() < deadline) {
1394
+ await sleep(250);
1395
+ try {
1396
+ if (await cdp.evaluate(chatGptThreadReadyExpression(conversationId)))
1397
+ return;
1398
+ }
1399
+ catch (error) {
1400
+ // Between documents the context is gone and the next poll lands on the
1401
+ // new one; a command timeout means the tab stopped answering.
1402
+ if (cdpCommandTimedOut(error))
1403
+ throw error;
1404
+ }
1405
+ }
1406
+ throw new ChatGptBrowserBlockerError(chatGptThreadUnavailableBlocker(url));
1407
+ }
1408
+ /**
1409
+ * The conversation a follow-up names cannot be opened.
1410
+ *
1411
+ * Retrying cannot undelete a thread, and the generic "resolve the visible
1412
+ * browser issue manually" this used to fall back to describes a browser that
1413
+ * is working fine - measured on a thread whose project had been deleted: the
1414
+ * cause was named in the message and then thrown away by the catch-all next
1415
+ * step underneath it.
1416
+ */
1417
+ export function chatGptThreadUnavailableBlocker(url) {
1418
+ return {
1419
+ code: "thread_unavailable",
1420
+ message: `ChatGPT did not open the conversation to continue (${url}). It may have been deleted, or its project was.`,
1421
+ retryable: false,
1422
+ next_step: "That conversation cannot be reached, and retrying will not bring it back. Send without --continue to start a new one, " +
1423
+ "or name a different consult with --continue-task <task_id> (`prodex pro list` shows them).",
1424
+ thread: url
1425
+ };
1426
+ }
1363
1427
  async function openFreshChatGptHome(cdp) {
1364
1428
  await cdp.evaluate(markDocumentForReloadExpression());
1365
1429
  await cdp.evaluate(`location.assign("https://chatgpt.com/")`);
@@ -1641,6 +1705,20 @@ export function reloadedDocumentReadyExpression(extraCondition = "true") {
1641
1705
  * home that fails to load, and the composer candidate is the real editor
1642
1706
  * rather than the broad selector that also matches a hidden fallback.
1643
1707
  */
1708
+ /**
1709
+ * True once the tab is on this conversation and has rendered a real composer.
1710
+ *
1711
+ * The id is compared rather than the whole URL because ChatGPT rewrites the
1712
+ * project part of it (measured: the same project appears with and without its
1713
+ * name), and the composer has to be the real editor rather than the hidden 0x0
1714
+ * fallback that the broad selector also matches.
1715
+ */
1716
+ export function chatGptThreadReadyExpression(conversationId) {
1717
+ return `(() => {${composerExpressionHelpers()}
1718
+ if (!location.href.includes(${JSON.stringify(conversationId)})) return false;
1719
+ return Boolean(findChatGptComposerCandidate());
1720
+ })()`;
1721
+ }
1644
1722
  export function freshChatGptHomeReadyExpression() {
1645
1723
  return reloadedDocumentReadyExpression(`/^https:\\/\\/chatgpt\\.com\\/?(?:[?#].*)?$/.test(location.href) && (() => {${composerExpressionHelpers()}
1646
1724
  return Boolean(findChatGptComposerCandidate());
@@ -1757,9 +1835,13 @@ export function composerProjectBinding(input) {
1757
1835
  const named = /^new\s+chat\s+in\s+(.+)$/i.exec(placeholder)?.[1] ?? /^(.+?)\uc5d0\uc11c\s*\uc0c8\s*\ucc44\ud305$/.exec(placeholder)?.[1];
1758
1836
  if (named)
1759
1837
  return named.trim().toLowerCase() === wanted ? "bound" : "elsewhere";
1760
- // The placeholder a plain new chat carries: recognised, and it names no
1761
- // project, so the composer belongs to none.
1762
- if (/^ask\s+chatgpt$/i.test(placeholder))
1838
+ // The label a plain new chat carries: recognised, and it names no project, so
1839
+ // the composer belongs to none. Two wordings measured on the same live root -
1840
+ // the hidden fallback textarea says "Ask ChatGPT" while the editor prodex
1841
+ // actually reads says "Chat with ChatGPT" - and reading only the first left
1842
+ // the clearest case of "this composer is not the project's" reported as a
1843
+ // label that could not be read.
1844
+ if (/^(?:ask|chat with)\s+chatgpt$/i.test(placeholder))
1763
1845
  return "elsewhere";
1764
1846
  return "unknown";
1765
1847
  }
@@ -3046,7 +3128,7 @@ async function readTranscriptAnswer(page, conversationId, sentPrompt) {
3046
3128
  const answer = resolveTranscriptCitations(transcript.text, transcript.references).trim();
3047
3129
  return answer.length > 0
3048
3130
  ? { classification: "answer", answer: { answer, modelSlug: transcript.modelSlug } }
3049
- : { classification: "pending" };
3131
+ : { classification: "no_text" };
3050
3132
  }
3051
3133
  // A page that has not reported the prompt posting within this long is worth
3052
3134
  // double-checking against the transcript; the probe is a couple of small fetches.
@@ -3086,7 +3168,10 @@ export async function sendChatGptPrompt(options) {
3086
3168
  if (options.newChat && normalizedTargetUrl) {
3087
3169
  throw new Error("newChat cannot be combined with targetUrl: a fresh chat navigates away from the pinned tab.");
3088
3170
  }
3089
- const pageResult = await findChatGptPage(port, computePageDiscoveryTimeout(timeoutMs), normalizedTargetUrl);
3171
+ // A resolved thread is reached by navigating, so page discovery must not
3172
+ // demand a tab already sitting on it.
3173
+ const requireTabAtTargetUrl = options.navigateToTargetUrl ? undefined : normalizedTargetUrl;
3174
+ const pageResult = await findChatGptPage(port, computePageDiscoveryTimeout(timeoutMs), requireTabAtTargetUrl);
3090
3175
  if (!pageResult.ok) {
3091
3176
  throwBlockerOrError(pageResult.blocker, "ChatGPT browser page is not available");
3092
3177
  }
@@ -3094,8 +3179,8 @@ export async function sendChatGptPrompt(options) {
3094
3179
  if (pageResult.blocker) {
3095
3180
  throw new ChatGptBrowserBlockerError(pageResult.blocker);
3096
3181
  }
3097
- if (normalizedTargetUrl) {
3098
- assertChatGptTargetTabAvailable(normalizedTargetUrl);
3182
+ if (requireTabAtTargetUrl) {
3183
+ assertChatGptTargetTabAvailable(requireTabAtTargetUrl);
3099
3184
  }
3100
3185
  assertChatGptPageAvailable();
3101
3186
  }
@@ -3242,14 +3327,21 @@ export async function sendChatGptPrompt(options) {
3242
3327
  awaitingResponseChoice: status.awaitingResponseChoice === true,
3243
3328
  ...(options.newChat !== undefined ? { newChat: options.newChat } : {}),
3244
3329
  ...(options.project !== undefined ? { project: options.project } : {}),
3245
- ...(options.projectNew !== undefined ? { projectNew: options.projectNew } : {})
3330
+ ...(options.projectNew !== undefined ? { projectNew: options.projectNew } : {}),
3331
+ // Navigating to another conversation leaves the parked one alone, the
3332
+ // same way a fresh chat or a project home does.
3333
+ ...(options.navigateToTargetUrl ? { newChat: true } : {})
3246
3334
  })) {
3247
3335
  throw new ChatGptBrowserBlockerError(chatGptResponseChoiceBlocker(true));
3248
3336
  }
3249
3337
  assertChatGptIdleAndReadyForPrompt(status, busyBlocker, true);
3250
- if (normalizedTargetUrl)
3251
- assertChatGptTargetUrlMatches(status.url, normalizedTargetUrl);
3252
- assertVisibleChatGptTab(status.visibilityState, status.url, normalizedTargetUrl);
3338
+ // Only a PINNED target has to be under the tab already; a resolved thread is
3339
+ // navigated to below, and asserting the match here would refuse the send for
3340
+ // the tab merely being somewhere else - which is the whole reason a
3341
+ // continuation resolves from records rather than from the tab.
3342
+ if (requireTabAtTargetUrl)
3343
+ assertChatGptTargetUrlMatches(status.url, requireTabAtTargetUrl);
3344
+ assertVisibleChatGptTab(status.visibilityState, status.url, requireTabAtTargetUrl);
3253
3345
  emitProgress("tab_ready");
3254
3346
  // Progress details deliberately avoid project names (receipts redact them too).
3255
3347
  const selectionSummary = [
@@ -3315,6 +3407,9 @@ export async function sendChatGptPrompt(options) {
3315
3407
  // keeps Work's composer, and switching afterwards does not move it.
3316
3408
  // Max and Ultra are rungs of Work's slider, so asking for one means staying
3317
3409
  // there; anything else belongs on Chat, whose top step is Pro.
3410
+ if (normalizedTargetUrl && options.navigateToTargetUrl) {
3411
+ await openChatGptThread(cdp, normalizedTargetUrl);
3412
+ }
3318
3413
  if (!effortNeedsWorkSurface(options.effort)) {
3319
3414
  // Leaving the current page is safe only for a send that was going to
3320
3415
  // navigate anyway; a continuation or a pinned tab has to be reloaded
@@ -3635,6 +3730,13 @@ export async function sendChatGptPrompt(options) {
3635
3730
  lastTranscriptClassification = transcript.classification;
3636
3731
  if (transcript.answer)
3637
3732
  return transcriptResult(transcript.answer);
3733
+ // A finished turn with no text is an answer of a different shape - an
3734
+ // image, measured - and waiting for words it will never write spends
3735
+ // the whole budget and then calls the result a timeout. The page must
3736
+ // agree it has stopped generating before this counts.
3737
+ if (transcript.classification === "no_text") {
3738
+ return transcriptResult({ answer: CHATGPT_NON_TEXT_ANSWER_NOTE, modelSlug: "" });
3739
+ }
3638
3740
  }
3639
3741
  if (shouldRecoverThreadNavigation({ pinnedThreadUrl, currentUrl: finalState?.url, lastTranscriptClassification })) {
3640
3742
  if (recoveredNavigations >= 2) {
@@ -4924,8 +5026,13 @@ export function activeComposerToolsExpression(labels) {
4924
5026
  // selection did not take.
4925
5027
  const el = document.querySelector('#prompt-textarea,[contenteditable="true"]');
4926
5028
  const form = el ? (el.closest("form") || el.parentElement) : null;
4927
- const text = (el ? el.innerText || "" : "") + String.fromCharCode(10) + (form ? form.innerText || "" : "");
4928
- return { ok: true, active: ${labelsJson}.filter((label) => text.includes(label)) };
5029
+ const text = ((el ? el.innerText || "" : "") + String.fromCharCode(10) + (form ? form.innerText || "" : "")).toLowerCase();
5030
+ // Case-insensitively: prodex carries the label as the menu spells it
5031
+ // ("Create image") while the page has been measured using "Create Image"
5032
+ // for the same tool. This is hardening, not a fix for a failure anyone has
5033
+ // seen - the create-image activation failure measured on this account
5034
+ // survives it, and its cause is still open.
5035
+ return { ok: true, active: ${labelsJson}.filter((label) => text.includes(String(label).toLowerCase())) };
4929
5036
  })()`;
4930
5037
  }
4931
5038
  export function composerTextStateExpression(expectedText, toolLabels = []) {
package/dist/cli-pro.js CHANGED
@@ -1313,6 +1313,9 @@ export async function runAskProCommand(rest, io) {
1313
1313
  port: browserPort,
1314
1314
  prompt: bundle.sendText,
1315
1315
  targetUrl: normalizedTargetUrl,
1316
+ // A thread prodex resolved from its own records is reached by
1317
+ // navigating; a --target-url the person confirmed is not moved.
1318
+ ...(continuedFromTaskId ? { navigateToTargetUrl: true } : {}),
1316
1319
  timeoutMs: browserTimeoutMs,
1317
1320
  ...(attachments.length > 0 ? { attachments } : {}),
1318
1321
  ...(tools.length > 0 ? { tools } : {}),
@@ -1432,6 +1435,13 @@ export async function runAskProCommand(rest, io) {
1432
1435
  io.stderr(warning);
1433
1436
  if (consult.modelSlug)
1434
1437
  io.stderr(`model_used: ${consult.modelSlug}`);
1438
+ // Which conversation this followed, and where the answer actually landed.
1439
+ // The progress line that says it is filtered out of MCP notes, so an
1440
+ // agent asking for a follow-up had no way to know which thread it got -
1441
+ // or whether the answer is in the project it asked for.
1442
+ if (continuedFromTaskId)
1443
+ io.stderr(`continued_from: ${continuedFromTaskId}`);
1444
+ io.stderr(`destination: ${destination.destination}${destination.verified === undefined ? "" : ` verified=${destination.verified}`}`);
1435
1445
  // "Can I count this as a Pro review?" - answered here rather than left
1436
1446
  // for whoever reads the receipt to work out from two other fields.
1437
1447
  const proVerified = proSelectionVerified({
@@ -1525,6 +1535,11 @@ export async function runAskProCommand(rest, io) {
1525
1535
  status: result.status,
1526
1536
  thread: consult.url,
1527
1537
  answer: result.summary,
1538
+ ...(continuedFromTaskId ? { continued_from: continuedFromTaskId } : {}),
1539
+ destination: {
1540
+ observed: destination.destination,
1541
+ ...(destination.verified !== undefined ? { verified: destination.verified } : {})
1542
+ },
1528
1543
  warnings: persistenceWarnings
1529
1544
  }, null, 2));
1530
1545
  }
package/dist/config.js CHANGED
@@ -252,8 +252,13 @@ export async function loadBrowserDefaults(cwd) {
252
252
  // stop applying with nothing said, so a consult that should have landed in
253
253
  // a project lands in the general chat and looks like it worked.
254
254
  if (!isMissingFileError(error)) {
255
+ // Say only what is true. Passing the flags explicitly does NOT get past
256
+ // this - the config is read before any of them are looked at - and
257
+ // advice that does not work is worse than none. Moving the file aside
258
+ // does work: a repo with no config has no defaults and sends fine.
255
259
  throw new Error(`${error instanceof Error ? error.message : String(error)} Until then prodex will not apply the browser defaults ` +
256
- `pinned there (project, model), so pass them explicitly if you need to send before fixing it.`, { cause: error });
260
+ `pinned there (project, model), and every send from this repo stops here. Move .bridge/config.local.json aside ` +
261
+ `if this repo does not need the HTTP MCP surface.`, { cause: error });
257
262
  }
258
263
  repo = undefined;
259
264
  }
@@ -28,6 +28,41 @@ export function chatGptProjectSlug(name) {
28
28
  .replace(/[^a-z0-9]+/g, "-")
29
29
  .replace(/^-+|-+$/g, "");
30
30
  }
31
+ /**
32
+ * Whether a recorded URL is a conversation someone can go back to.
33
+ *
34
+ * Not everything prodex records as a "thread" is one. Measured across this
35
+ * machine's records: six of them are `https://chatgpt.com/?temporary-chat=true`
36
+ * - a temporary chat is never saved, so returning to that URL opens a fresh
37
+ * empty one. Continuing into it would look like a follow-up and be a new
38
+ * conversation with none of the context, which is the failure --continue
39
+ * exists to remove rather than reproduce.
40
+ */
41
+ export function isChatGptConversationUrl(threadUrl) {
42
+ return /\/c\/[^/?#]+/.test(threadUrl);
43
+ }
44
+ /**
45
+ * The project ids these recorded threads have been seen using, by project name.
46
+ *
47
+ * Some thread URLs carry the project name after its id and some carry only the
48
+ * id (measured: three of this machine's hundred-odd records). The named ones
49
+ * teach the mapping, so the bare ones can still be recognised instead of being
50
+ * invisible to every follow-up - which would quietly continue an OLDER
51
+ * conversation while the newest one sat unmatched.
52
+ */
53
+ export function projectIdsByName(threadUrls) {
54
+ const byName = new Map();
55
+ for (const url of threadUrls) {
56
+ const match = /\/g\/g-p-([0-9a-f]+)-([^/?#]+)/i.exec(url);
57
+ if (!match)
58
+ continue;
59
+ const name = match[2].toLowerCase();
60
+ const ids = byName.get(name) ?? new Set();
61
+ ids.add(match[1].toLowerCase());
62
+ byName.set(name, ids);
63
+ }
64
+ return byName;
65
+ }
31
66
  /**
32
67
  * Whether a recorded thread belongs to the project this send is for.
33
68
  *
@@ -35,18 +70,22 @@ export function chatGptProjectSlug(name) {
35
70
  * so a follow-up meant for the general chat cannot walk into a project - and a
36
71
  * project's follow-up cannot land in another project's conversation.
37
72
  */
38
- export function threadMatchesProject(threadUrl, project) {
73
+ export function threadMatchesProject(threadUrl, project, knownProjectIds) {
39
74
  const projectSegment = /\/g\/(g-p-[^/?#]+)/.exec(threadUrl)?.[1];
40
75
  if (!project)
41
76
  return projectSegment === undefined;
42
77
  if (!projectSegment)
43
78
  return false;
44
79
  const slug = chatGptProjectSlug(project);
45
- if (!slug)
46
- return false;
47
80
  // The id comes first and the name follows it, so an exact suffix match keeps
48
81
  // "notes" from answering for "notes-archive".
49
- return projectSegment.toLowerCase().endsWith(`-${slug}`);
82
+ if (slug && projectSegment.toLowerCase().endsWith(`-${slug}`))
83
+ return true;
84
+ // No name in the URL: fall back to the id this project has been seen using.
85
+ // Nothing is guessed - the id came from another record of the same project -
86
+ // and it survives a rename, which the name comparison cannot.
87
+ const id = /^g-p-([0-9a-f]+)/i.exec(projectSegment)?.[1]?.toLowerCase();
88
+ return Boolean(id && knownProjectIds?.has(id));
50
89
  }
51
90
  /**
52
91
  * The thread a follow-up should continue, or why it cannot be resolved.
@@ -57,9 +96,16 @@ export function threadMatchesProject(threadUrl, project) {
57
96
  * the conversation is the failure this exists to prevent.
58
97
  */
59
98
  export function resolveContinuationThread(input) {
60
- const withThread = input.consults.filter((consult) => Boolean(consult.thread));
99
+ const withThread = input.consults.filter((consult) => consult.thread && isChatGptConversationUrl(consult.thread));
61
100
  if (input.taskId) {
62
101
  const named = withThread.find((consult) => consult.taskId === input.taskId);
102
+ const recordedButUnusable = !named && input.consults.some((consult) => consult.taskId === input.taskId);
103
+ if (recordedButUnusable) {
104
+ return {
105
+ error: `Consult "${input.taskId}" has no conversation to continue - it was a temporary chat, or it never reached one. ` +
106
+ `A temporary chat is never saved, so there is nothing to go back to.`
107
+ };
108
+ }
63
109
  if (!named) {
64
110
  return {
65
111
  error: `No recorded consult thread for "${input.taskId}". List what is here with \`prodex pro list\`, ` +
@@ -68,9 +114,12 @@ export function resolveContinuationThread(input) {
68
114
  }
69
115
  return { target: { taskId: named.taskId, thread: named.thread } };
70
116
  }
117
+ const knownProjectIds = input.project
118
+ ? projectIdsByName(withThread.map((consult) => consult.thread)).get(chatGptProjectSlug(input.project))
119
+ : undefined;
71
120
  const candidates = withThread
72
121
  .filter((consult) => consult.status === "done")
73
- .filter((consult) => threadMatchesProject(consult.thread, input.project))
122
+ .filter((consult) => threadMatchesProject(consult.thread, input.project, knownProjectIds))
74
123
  .sort((a, b) => (b.createdAt ?? "").localeCompare(a.createdAt ?? ""));
75
124
  const latest = candidates[0];
76
125
  if (!latest) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdie006/prodex",
3
- "version": "0.40.3",
3
+ "version": "0.40.4",
4
4
  "description": "Local receipt bus for coordinating Codex execution with ChatGPT Pro/Projects consultation.",
5
5
  "author": "youdie006",
6
6
  "license": "MIT",