@youdie006/prodex 0.40.6 → 0.40.9

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/safe-file.js CHANGED
@@ -1,11 +1,62 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { constants } from "node:fs";
3
- import { lstat, open, realpath, rename, rm } from "node:fs/promises";
3
+ import { chmod, link, lstat, mkdir, open, realpath, rename, rm } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  let testHooks = {};
6
6
  export function setSafeFileTestHooks(hooks) {
7
7
  testHooks = hooks;
8
8
  }
9
+ /**
10
+ * Serialize cooperating processes through a complete, atomically published
11
+ * lock record. A live process is never evicted based on elapsed time: callers
12
+ * must cancel their own work before releasing the lease.
13
+ */
14
+ export async function withCrossProcessFileLock(filePath, options, fn) {
15
+ const parentSnapshot = await prepareFileLockParent(filePath, options.privateParent === true);
16
+ const deadline = Date.now() + Math.max(0, options.waitMs);
17
+ const retryMs = Math.max(1, options.retryMs ?? 50);
18
+ let waited = false;
19
+ let retriedMissingAtDeadline = false;
20
+ let lease;
21
+ for (;;) {
22
+ lease = await tryAcquireFileLock(filePath, parentSnapshot);
23
+ if (lease)
24
+ break;
25
+ const snapshot = await readFileLockSnapshot(filePath, parentSnapshot);
26
+ if (!snapshot) {
27
+ // The holder commonly disappears between our failed link and inspection.
28
+ // Retry that acquisition once even at the deadline, but do not let a
29
+ // rapidly flapping path bypass a bounded caller's wait budget forever.
30
+ if (Date.now() >= deadline) {
31
+ if (retriedMissingAtDeadline)
32
+ throw options.unavailableError();
33
+ retriedMissingAtDeadline = true;
34
+ }
35
+ else {
36
+ await delay(Math.min(10, Math.max(1, deadline - Date.now())));
37
+ }
38
+ continue;
39
+ }
40
+ retriedMissingAtDeadline = false;
41
+ const holderAlive = snapshot.holder.pid !== undefined && processIsAlive(snapshot.holder.pid);
42
+ if (!holderAlive && (await reapFileLockSnapshot(filePath, snapshot, parentSnapshot)))
43
+ continue;
44
+ if (Date.now() >= deadline) {
45
+ throw holderAlive ? options.busyError(snapshot.holder) : options.unavailableError();
46
+ }
47
+ if (!waited) {
48
+ waited = true;
49
+ options.onWait?.(snapshot.holder);
50
+ }
51
+ await delay(Math.min(retryMs, Math.max(1, deadline - Date.now())));
52
+ }
53
+ try {
54
+ return await fn();
55
+ }
56
+ finally {
57
+ await releaseFileLockLease(filePath, lease, parentSnapshot);
58
+ }
59
+ }
9
60
  export async function readVerifiedUtf8File(filePath, validate, options = {}) {
10
61
  await validate();
11
62
  const parentSnapshot = await captureParentSnapshot(filePath);
@@ -286,9 +337,206 @@ async function openNoFollow(filePath, flags, operation, mode) {
286
337
  throw error;
287
338
  }
288
339
  }
340
+ async function prepareFileLockParent(filePath, makePrivate) {
341
+ const parentPath = path.dirname(filePath);
342
+ await mkdir(parentPath, { recursive: true, mode: 0o700 });
343
+ const parentStat = await lstat(parentPath);
344
+ if (parentStat.isSymbolicLink() || !parentStat.isDirectory()) {
345
+ throw new Error("Lock parent must be a real directory and must not be a symlink");
346
+ }
347
+ if (makePrivate)
348
+ await chmod(parentPath, 0o700);
349
+ const snapshot = await captureParentSnapshot(filePath);
350
+ if (!snapshot)
351
+ throw new Error("Lock files cannot use a descriptor-backed parent path");
352
+ await assertParentSnapshotStable(snapshot);
353
+ return snapshot;
354
+ }
355
+ async function tryAcquireFileLock(filePath, parentSnapshot) {
356
+ const owner = {
357
+ pid: process.pid,
358
+ nonce: randomUUID(),
359
+ started_at: new Date().toISOString()
360
+ };
361
+ const tokenPath = fileLockTokenPath(filePath, owner);
362
+ const handle = await openStableNoFollow(tokenPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, "write", parentSnapshot, 0o600);
363
+ try {
364
+ await handle.writeFile(`${JSON.stringify(owner)}\n`, "utf8");
365
+ await handle.chmod(0o600);
366
+ }
367
+ finally {
368
+ await handle.close();
369
+ }
370
+ try {
371
+ await assertParentSnapshotStable(parentSnapshot);
372
+ await link(tokenPath, filePath);
373
+ return { owner, tokenPath };
374
+ }
375
+ catch (error) {
376
+ await rm(tokenPath, { force: true }).catch(() => undefined);
377
+ if (isErrorCode(error, "EEXIST"))
378
+ return undefined;
379
+ throw error;
380
+ }
381
+ }
382
+ async function readFileLockSnapshot(filePath, parentSnapshot) {
383
+ let pathIdentity;
384
+ try {
385
+ const pathStat = await lstat(filePath, { bigint: true });
386
+ pathIdentity = { dev: pathStat.dev, ino: pathStat.ino };
387
+ if (pathStat.isSymbolicLink() || !pathStat.isFile()) {
388
+ return { identity: pathIdentity, holder: {}, unsafe: true };
389
+ }
390
+ }
391
+ catch (error) {
392
+ if (isMissingFileError(error))
393
+ return undefined;
394
+ throw error;
395
+ }
396
+ let handle;
397
+ try {
398
+ handle = await openStableNoFollow(filePath, constants.O_RDONLY, "read", parentSnapshot);
399
+ }
400
+ catch (error) {
401
+ if (isMissingFileError(error))
402
+ return undefined;
403
+ return { identity: pathIdentity, holder: {}, unsafe: true };
404
+ }
405
+ try {
406
+ const stat = await handle.stat({ bigint: true });
407
+ if (!stat.isFile())
408
+ return { identity: pathIdentity, holder: {}, unsafe: true };
409
+ const identity = { dev: stat.dev, ino: stat.ino };
410
+ if (stat.size > 16384n)
411
+ return { identity, holder: {} };
412
+ const content = await handle.readFile({ encoding: "utf8" });
413
+ const holder = parseFileLockHolder(content);
414
+ return { identity, holder, owner: parseFileLockOwner(holder) };
415
+ }
416
+ catch {
417
+ return { identity: pathIdentity, holder: {} };
418
+ }
419
+ finally {
420
+ await handle.close();
421
+ }
422
+ }
423
+ function parseFileLockHolder(content) {
424
+ try {
425
+ const parsed = JSON.parse(content);
426
+ return {
427
+ pid: Number.isSafeInteger(parsed.pid) && Number(parsed.pid) > 0 ? Number(parsed.pid) : undefined,
428
+ nonce: typeof parsed.nonce === "string" && /^[A-Za-z0-9-]{1,128}$/.test(parsed.nonce) ? parsed.nonce : undefined,
429
+ started_at: typeof parsed.started_at === "string" ? parsed.started_at : undefined
430
+ };
431
+ }
432
+ catch {
433
+ return {};
434
+ }
435
+ }
436
+ function parseFileLockOwner(holder) {
437
+ if (holder.pid === undefined || holder.nonce === undefined)
438
+ return undefined;
439
+ return { pid: holder.pid, nonce: holder.nonce, started_at: holder.started_at };
440
+ }
441
+ async function releaseFileLockLease(filePath, lease, parentSnapshot) {
442
+ try {
443
+ const [current, tokenIdentity] = await Promise.all([
444
+ readFileLockSnapshot(filePath, parentSnapshot),
445
+ readRegularFileIdentity(lease.tokenPath)
446
+ ]);
447
+ if (current?.owner?.pid === lease.owner.pid &&
448
+ current.owner.nonce === lease.owner.nonce &&
449
+ tokenIdentity &&
450
+ sameFileIdentity(current.identity, tokenIdentity)) {
451
+ await rm(filePath, { force: true });
452
+ }
453
+ }
454
+ catch {
455
+ // A release is best-effort; the unique token cleanup below cannot affect a successor.
456
+ }
457
+ finally {
458
+ await rm(lease.tokenPath, { force: true }).catch(() => undefined);
459
+ }
460
+ }
461
+ async function reapFileLockSnapshot(filePath, snapshot, parentSnapshot) {
462
+ if (snapshot.unsafe)
463
+ return false;
464
+ return reapClaimedFileLock(filePath, snapshot, parentSnapshot);
465
+ }
466
+ async function reapClaimedFileLock(filePath, snapshot, parentSnapshot) {
467
+ const claimPath = path.join(path.dirname(filePath), `.${path.basename(filePath)}.reap`);
468
+ try {
469
+ await link(filePath, claimPath);
470
+ }
471
+ catch (error) {
472
+ if (isMissingFileError(error))
473
+ return true;
474
+ // An interrupted reaper requires manual cleanup after all users stop.
475
+ // Reaping this claim recursively would reintroduce successor-unlink races.
476
+ if (isErrorCode(error, "EEXIST"))
477
+ return false;
478
+ throw error;
479
+ }
480
+ try {
481
+ await testHooks.afterLockReapClaim?.(filePath);
482
+ const [claimIdentity, current, ownerTokenIdentity] = await Promise.all([
483
+ readRegularFileIdentity(claimPath),
484
+ readFileLockSnapshot(filePath, parentSnapshot),
485
+ snapshot.owner ? readRegularFileIdentity(fileLockTokenPath(filePath, snapshot.owner)) : undefined
486
+ ]);
487
+ if (!claimIdentity ||
488
+ !current ||
489
+ !sameFileIdentity(claimIdentity, snapshot.identity) ||
490
+ !sameFileIdentity(current.identity, snapshot.identity)) {
491
+ return false;
492
+ }
493
+ await rm(filePath, { force: true });
494
+ if (snapshot.owner && ownerTokenIdentity && sameFileIdentity(ownerTokenIdentity, snapshot.identity)) {
495
+ await rm(fileLockTokenPath(filePath, snapshot.owner), { force: true }).catch(() => undefined);
496
+ }
497
+ return true;
498
+ }
499
+ finally {
500
+ await rm(claimPath, { force: true }).catch(() => undefined);
501
+ }
502
+ }
503
+ async function readRegularFileIdentity(filePath) {
504
+ try {
505
+ const stat = await lstat(filePath, { bigint: true });
506
+ if (stat.isSymbolicLink() || !stat.isFile())
507
+ return undefined;
508
+ return { dev: stat.dev, ino: stat.ino };
509
+ }
510
+ catch (error) {
511
+ if (isMissingFileError(error))
512
+ return undefined;
513
+ throw error;
514
+ }
515
+ }
516
+ function fileLockTokenPath(filePath, owner) {
517
+ return path.join(path.dirname(filePath), `.${path.basename(filePath)}.${owner.pid}.${owner.nonce}.owner`);
518
+ }
519
+ function sameFileIdentity(left, right) {
520
+ return left.dev === right.dev && left.ino === right.ino;
521
+ }
522
+ function processIsAlive(pid) {
523
+ try {
524
+ process.kill(pid, 0);
525
+ return true;
526
+ }
527
+ catch (error) {
528
+ return error.code === "EPERM";
529
+ }
530
+ }
531
+ function delay(ms) {
532
+ return new Promise((resolve) => setTimeout(resolve, ms));
533
+ }
289
534
  function procFdPath(fd) {
290
535
  return `/proc/self/fd/${fd}`;
291
536
  }
292
537
  function isMissingFileError(error) {
293
538
  return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
294
539
  }
540
+ function isErrorCode(error, code) {
541
+ return typeof error === "object" && error !== null && "code" in error && error.code === code;
542
+ }
package/dist/schema.js CHANGED
@@ -1,5 +1,12 @@
1
1
  import { z } from "zod";
2
2
  export const SCHEMA_VERSION = 1;
3
+ export const SessionKeySchema = z
4
+ .string()
5
+ .trim()
6
+ .min(1)
7
+ .max(128)
8
+ .regex(/^[A-Za-z0-9][A-Za-z0-9._:@/-]*$/, "Session key must be an identifier, not prompt text");
9
+ export const ProdexRequestIdSchema = z.string().regex(/^[a-f0-9]{32}$/);
3
10
  export const AdapterSchema = z.enum(["cli", "mcp", "manual", "oracle", "chatgpt-control"]);
4
11
  export const TaskStatusSchema = z.enum(["new", "claimed", "done", "blocked"]);
5
12
  export const ResultStatusSchema = z.enum(["done", "blocked"]);
@@ -83,6 +90,7 @@ export const SessionSchema = z.object({
83
90
  id: z.string().regex(/^sess_\d{8}_\d{6}_[a-z0-9-]+$/),
84
91
  direction: z.enum(["codex_to_chatgpt", "chatgpt_to_codex", "claude_to_codex"]),
85
92
  backend: AdapterSchema,
93
+ session_key: SessionKeySchema.optional(),
86
94
  project: z.string().optional(),
87
95
  thread: z.string().optional(),
88
96
  task_id: z.string().optional(),
package/dist/store.js CHANGED
@@ -389,7 +389,13 @@ export class BridgeStore {
389
389
  if (artifact.sha256 && sha256(content) !== artifact.sha256) {
390
390
  throw new Error(`Result artifact changed after finalization for ${taskId}: ${artifact.path} sha256 mismatch`);
391
391
  }
392
- return { artifact, content };
392
+ return {
393
+ artifact,
394
+ content,
395
+ ...(!artifact.sha256 ? {
396
+ warnings: ["legacy_artifact_unverified: this artifact has no recorded sha256; its current contents cannot be verified against finalization."]
397
+ } : {})
398
+ };
393
399
  }
394
400
  async withResultArtifactHashes(artifacts) {
395
401
  const withHashes = [];
@@ -436,6 +442,7 @@ export class BridgeStore {
436
442
  id,
437
443
  direction: input.direction,
438
444
  backend: input.backend,
445
+ session_key: input.session_key,
439
446
  project: input.project,
440
447
  thread: input.thread,
441
448
  task_id: input.task_id,
@@ -452,6 +459,7 @@ export class BridgeStore {
452
459
  id: input.id,
453
460
  direction: input.direction,
454
461
  backend: input.backend,
462
+ session_key: input.session_key ?? existing?.session_key,
455
463
  project: input.project,
456
464
  thread: input.thread,
457
465
  task_id: input.task_id,
@@ -476,6 +484,7 @@ export class BridgeStore {
476
484
  id: existing.id,
477
485
  direction: existing.direction,
478
486
  backend: existing.backend,
487
+ session_key: existing.session_key,
479
488
  project: existing.project,
480
489
  thread: existing.thread,
481
490
  task_id: existing.task_id,
package/dist/tui-flow.js CHANGED
@@ -10,7 +10,6 @@
10
10
  */
11
11
  export const SEND_KINDS = [
12
12
  { id: "chat", label: "Normal chat", hint: "ordinary Pro answer", tools: [] },
13
- { id: "deep-research", label: "Deep research", hint: "browsed report, runs about 10 minutes", tools: ["deep-research"] },
14
13
  { id: "web-search", label: "Web search", hint: "current facts, with sources", tools: ["web-search"] },
15
14
  { id: "create-image", label: "Create image", tools: ["create-image"] }
16
15
  ];
package/dist/tui-run.js CHANGED
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import readline from "node:readline";
10
10
  import { renderBanner } from "./banner.js";
11
+ import { shellQuote } from "./cli-args.js";
11
12
  import { destinationChoices, effortChoices, SEND_KINDS } from "./tui-flow.js";
12
13
  import { consultArgsFromChoices, conversationsInProject, conversationThreadUrl, moveCursor, parseAttachmentLine, progressLabel, renderContextPanel, renderProgressBar, renderSelectList } from "./tui.js";
13
14
  const ESC = "";
@@ -32,11 +33,18 @@ class KeyQueue {
32
33
  input;
33
34
  pending = [];
34
35
  waiting;
36
+ ended = false;
35
37
  constructor(input) {
36
38
  this.input = input;
37
39
  this.input.on("keypress", this.onKey);
40
+ this.input.on("end", this.onEnd);
41
+ this.input.on("close", this.onEnd);
42
+ if (this.input.readableEnded || this.input.destroyed)
43
+ this.onEnd();
38
44
  }
39
45
  onKey = (_str, key) => {
46
+ if (this.ended)
47
+ return;
40
48
  const value = key ?? {};
41
49
  if (this.waiting) {
42
50
  const resolve = this.waiting;
@@ -46,7 +54,16 @@ class KeyQueue {
46
54
  }
47
55
  this.pending.push(value);
48
56
  };
57
+ onEnd = () => {
58
+ this.ended = true;
59
+ this.pending.length = 0;
60
+ const resolve = this.waiting;
61
+ this.waiting = undefined;
62
+ resolve?.({ name: "c", ctrl: true });
63
+ };
49
64
  next() {
65
+ if (this.ended)
66
+ return Promise.resolve({ name: "c", ctrl: true });
50
67
  const buffered = this.pending.shift();
51
68
  if (buffered)
52
69
  return Promise.resolve(buffered);
@@ -60,6 +77,8 @@ class KeyQueue {
60
77
  }
61
78
  dispose() {
62
79
  this.input.off("keypress", this.onKey);
80
+ this.input.off("end", this.onEnd);
81
+ this.input.off("close", this.onEnd);
63
82
  }
64
83
  }
65
84
  let keys;
@@ -69,7 +88,7 @@ function readKey(_io) {
69
88
  return keys.next();
70
89
  }
71
90
  function isCancel(key) {
72
- return (key.ctrl === true && key.name === "c") || key.name === "escape" || key.name === "q";
91
+ return (key.ctrl === true && (key.name === "c" || key.name === "d")) || key.name === "escape" || key.name === "q";
73
92
  }
74
93
  // Terminals disagree about the Enter key: a carriage return arrives as
75
94
  // "return", a line feed as "enter". Accept either, or the picker looks frozen.
@@ -121,15 +140,38 @@ function numericChoice(key, length) {
121
140
  async function askLine(io, question) {
122
141
  io.input.setRawMode?.(false);
123
142
  const rl = readline.createInterface({ input: io.input, output: process.stdout, terminal: true });
124
- const answer = await new Promise((resolve) => rl.question(question, resolve));
125
- rl.close();
126
- // Closing the line reader detaches the keypress plumbing and pauses the
127
- // stream, so the next picker would sit there ignoring every key. Re-arm both.
128
- io.input.setRawMode?.(true);
129
- readline.emitKeypressEvents(io.input);
130
- io.input.resume();
131
- keys?.drain();
132
- return answer.trim();
143
+ try {
144
+ const answer = await new Promise((resolve) => {
145
+ let settled = false;
146
+ function settle(value) {
147
+ if (settled)
148
+ return;
149
+ settled = true;
150
+ rl.off("SIGINT", onInterrupt);
151
+ rl.off("close", onClose);
152
+ resolve(value);
153
+ }
154
+ function onInterrupt() {
155
+ settle(undefined);
156
+ }
157
+ function onClose() {
158
+ settle(undefined);
159
+ }
160
+ rl.once("SIGINT", onInterrupt);
161
+ rl.once("close", onClose);
162
+ rl.question(question, (value) => settle(value));
163
+ });
164
+ return answer?.trim();
165
+ }
166
+ finally {
167
+ rl.close();
168
+ // Closing the line reader detaches the keypress plumbing and pauses the
169
+ // stream, so the next picker would sit there ignoring every key. Re-arm both.
170
+ io.input.setRawMode?.(true);
171
+ readline.emitKeypressEvents(io.input);
172
+ io.input.resume();
173
+ keys?.drain();
174
+ }
133
175
  }
134
176
  /**
135
177
  * Walk the questions a send needs, then run it with a moving progress bar.
@@ -253,6 +295,8 @@ export async function runInteractiveConsult(io, deps) {
253
295
  }
254
296
  else if (destination === "project-new") {
255
297
  projectName = await askLine(io, `${CLEAR}${header}New project\n\n name: `);
298
+ if (projectName === undefined)
299
+ return cancel(io);
256
300
  if (!projectName)
257
301
  return cancel(io);
258
302
  projectMode = "new";
@@ -265,6 +309,8 @@ export async function runInteractiveConsult(io, deps) {
265
309
  const kindLabel = SEND_KINDS[kindChoice].label;
266
310
  const prompt = await askLine(io, `${kindLabel} Step ${steps} of ${steps}\n\n prompt: `);
267
311
  io.write(HIDE_CURSOR);
312
+ if (prompt === undefined)
313
+ return cancel(io);
268
314
  if (prompt.length === 0) {
269
315
  io.write("Nothing to ask.\n");
270
316
  return 1;
@@ -273,8 +319,11 @@ export async function runInteractiveConsult(io, deps) {
273
319
  // image, and the picker had no way to say so. Asked after the prompt, and
274
320
  // skipped by pressing enter, so the common case costs one keystroke.
275
321
  io.write(SHOW_CURSOR);
276
- const attachments = parseAttachmentLine(await askLine(io, "\n attach files? repo-relative paths, space separated, enter to skip\n files: "));
322
+ const attachmentLine = await askLine(io, "\n attach files? repo-relative paths, space separated, enter to skip\n files: ");
277
323
  io.write(HIDE_CURSOR);
324
+ if (attachmentLine === undefined)
325
+ return cancel(io);
326
+ const attachments = parseAttachmentLine(attachmentLine);
278
327
  const choices = {
279
328
  prompt,
280
329
  projectMode,
@@ -295,20 +344,8 @@ export async function runInteractiveConsult(io, deps) {
295
344
  // stop", and under raw mode that promise was false for the whole ten
296
345
  // minutes a deep research send runs. No key is read from here on.
297
346
  io.input.setRawMode?.(false);
298
- // --target-url confirms which conversation a send means; it deliberately
299
- // does not navigate. Picking one from a list IS a request to go there, so
300
- // move the tab first and let the flag confirm it landed.
301
- if (targetUrl && deps.openThread) {
302
- io.write("Opening the conversation you picked...\n");
303
- if (!(await deps.openThread(targetUrl))) {
304
- io.write(`Could not open ${targetUrl} in the dedicated browser.\n`);
305
- return 1;
306
- }
307
- }
308
347
  io.write(`Sending. Equivalent command:\n prodex ${formatCommand(args)}\n\n`);
309
- // Deep research runs about ten minutes; an ordinary Pro answer, minutes.
310
- // Fill the bar against that so the wait has a shape.
311
- const budgetMs = tools.includes("deep-research") ? 30 * 60_000 : 20 * 60_000;
348
+ const budgetMs = 20 * 60_000;
312
349
  const startedAt = now();
313
350
  let label = "starting";
314
351
  let tick = 0;
@@ -345,5 +382,5 @@ function cancel(io) {
345
382
  }
346
383
  /** Quote only what a shell would need quoted, so the echo can be pasted. */
347
384
  export function formatCommand(args) {
348
- return args.map((arg) => (/^[A-Za-z0-9._\-/:=]+$/.test(arg) ? arg : JSON.stringify(arg))).join(" ");
385
+ return args.map(shellQuote).join(" ");
349
386
  }
package/dist/tui.js CHANGED
@@ -75,35 +75,33 @@ export function parseAttachmentLine(line) {
75
75
  /**
76
76
  * Projects with the id their conversations are tagged with.
77
77
  *
78
- * The sidebar gives names only, which is enough to ENTER a project but not to
79
- * tell which chats live in it - and "open the project, then pick the session
80
- * inside it" needs exactly that link.
78
+ * Read only rendered links. A project id is accepted only when the visible
79
+ * anchor itself has a canonical ChatGPT project URL.
81
80
  */
82
81
  export function projectsWithIdsExpression() {
83
- return `(async () => {
84
- let token = "";
85
- try {
86
- const session = await fetch("/api/auth/session", { credentials: "include" });
87
- if (!session.ok) return [];
88
- const parsed = await session.json();
89
- token = (parsed && parsed.accessToken) || "";
90
- } catch (error) {
91
- return [];
92
- }
93
- try {
94
- const response = await fetch("/backend-api/gizmos/snorlax/sidebar", {
95
- credentials: "include",
96
- headers: token ? { Authorization: "Bearer " + token } : {}
97
- });
98
- if (!response.ok) return [];
99
- const listed = await response.json();
100
- return ((listed && listed.items) || [])
101
- .map((item) => item && item.gizmo)
102
- .filter((gizmo) => gizmo && gizmo.id)
103
- .map((gizmo) => ({ id: gizmo.id, name: ((gizmo.display && gizmo.display.name) || gizmo.name || "").trim() || gizmo.id }));
104
- } catch (error) {
105
- return [];
82
+ return `(() => {
83
+ const out = [];
84
+ const seen = new Set();
85
+ for (const anchor of document.querySelectorAll("a[href]")) {
86
+ if (out.length >= 100) break;
87
+ if (typeof anchor.getClientRects !== "function" || anchor.getClientRects().length === 0) continue;
88
+ let url;
89
+ try {
90
+ url = new URL(anchor.getAttribute("href") || "", "https://chatgpt.com");
91
+ } catch (error) {
92
+ continue;
93
+ }
94
+ if (url.protocol !== "https:" || url.hostname !== "chatgpt.com" || url.port || url.username || url.password) continue;
95
+ const match = /^\\/g\\/g-p-([0-9a-f]{1,128})(?:-[^/]+)?\\/project\\/?$/i.exec(url.pathname);
96
+ if (!match) continue;
97
+ const id = "g-p-" + match[1].toLowerCase();
98
+ if (seen.has(id)) continue;
99
+ const name = ((anchor.textContent || anchor.getAttribute("aria-label") || "").replace(/\\s+/g, " ").trim()).slice(0, 200);
100
+ if (!name) continue;
101
+ seen.add(id);
102
+ out.push({ id, name });
106
103
  }
104
+ return out;
107
105
  })()`;
108
106
  }
109
107
  /** The conversations that live inside a project, or all of them without one. */
@@ -114,37 +112,53 @@ export function conversationsInProject(conversations, projectId) {
114
112
  }
115
113
  /**
116
114
  * Recent conversations with their titles, for the "continue an existing chat"
117
- * list. Only the sidebar listing is fetched - the transcripts themselves are
118
- * large and nothing here needs them.
115
+ * list. This is deliberately limited to rendered links; no account API or
116
+ * transcript is read. A project association is included only when that same
117
+ * visible conversation URL carries the project id.
119
118
  */
120
119
  export function recentConversationTitlesExpression(limit = 10) {
121
- return `(async () => {
122
- let token = "";
123
- try {
124
- const session = await fetch("/api/auth/session", { credentials: "include" });
125
- if (!session.ok) return [];
126
- const parsed = await session.json();
127
- token = (parsed && parsed.accessToken) || "";
128
- } catch (error) {
129
- return [];
130
- }
131
- try {
132
- const response = await fetch("/backend-api/conversations?offset=0&limit=${limit}&order=updated", {
133
- credentials: "include",
134
- headers: token ? { Authorization: "Bearer " + token } : {}
135
- });
136
- if (!response.ok) return [];
137
- const listed = await response.json();
138
- return ((listed && listed.items) || [])
139
- .filter((item) => item && item.id)
140
- .map((item) => ({
141
- id: item.id,
142
- title: (item.title || "").trim() || "Untitled",
143
- ...(item.gizmo_id ? { gizmoId: item.gizmo_id } : {})
144
- }));
145
- } catch (error) {
146
- return [];
120
+ const boundedLimit = Number.isFinite(limit) ? Math.max(0, Math.min(100, Math.floor(limit))) : 10;
121
+ return `(() => {
122
+ const out = [];
123
+ const seen = new Map();
124
+ const ambiguousProjects = new Set();
125
+ let inspected = 0;
126
+ for (const anchor of document.querySelectorAll("a[href]")) {
127
+ if (++inspected > 2000) break;
128
+ if (typeof anchor.getClientRects !== "function" || anchor.getClientRects().length === 0) continue;
129
+ let url;
130
+ try {
131
+ url = new URL(anchor.getAttribute("href") || "", "https://chatgpt.com");
132
+ } catch (error) {
133
+ continue;
134
+ }
135
+ if (url.protocol !== "https:" || url.hostname !== "chatgpt.com" || url.port || url.username || url.password) continue;
136
+ const projectMatch = /^\\/g\\/g-p-([0-9a-f]{1,128})(?:-[^/]+)?\\/c\\/([0-9a-f-]{16,128})\\/?$/i.exec(url.pathname);
137
+ const plainMatch = /^\\/c\\/([0-9a-f-]{16,128})\\/?$/i.exec(url.pathname);
138
+ const id = (projectMatch && projectMatch[2]) || (plainMatch && plainMatch[1]);
139
+ if (!id) continue;
140
+ const gizmoId = projectMatch ? "g-p-" + projectMatch[1].toLowerCase() : undefined;
141
+ const existing = seen.get(id);
142
+ if (existing) {
143
+ if (gizmoId && !ambiguousProjects.has(id)) {
144
+ if (existing.gizmoId && existing.gizmoId !== gizmoId) {
145
+ delete existing.gizmoId;
146
+ ambiguousProjects.add(id);
147
+ } else existing.gizmoId = gizmoId;
148
+ }
149
+ continue;
150
+ }
151
+ if (out.length >= ${boundedLimit}) continue;
152
+ const title = ((anchor.textContent || anchor.getAttribute("aria-label") || "").replace(/\\s+/g, " ").trim()).slice(0, 300) || "Untitled";
153
+ const entry = {
154
+ id,
155
+ title,
156
+ ...(gizmoId ? { gizmoId } : {})
157
+ };
158
+ seen.set(id, entry);
159
+ out.push(entry);
147
160
  }
161
+ return out;
148
162
  })()`;
149
163
  }
150
164
  const ESC = "";