@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/README.md +31 -13
- package/dist/browser-send-lock.js +10 -131
- package/dist/chatgpt-browser.js +428 -746
- package/dist/cli-args.js +2 -0
- package/dist/cli-help.js +30 -24
- package/dist/cli-ledger.js +2 -0
- package/dist/cli-pro.js +328 -121
- package/dist/cli-server.js +2 -2
- package/dist/cli.js +1 -4
- package/dist/config.js +3 -3
- package/dist/continue-thread.js +9 -2
- package/dist/http-mcp.js +1 -1
- package/dist/issue-report.js +9 -4
- package/dist/mcp-tools.js +42 -10
- package/dist/mcp.js +11 -7
- package/dist/registry.js +54 -6
- package/dist/repo-write.js +22 -2
- package/dist/safe-file.js +249 -1
- package/dist/schema.js +8 -0
- package/dist/store.js +10 -1
- package/dist/tui-flow.js +0 -1
- package/dist/tui-run.js +62 -25
- package/dist/tui.js +68 -54
- package/docs/claude.md +6 -2
- package/docs/cli-reference.md +18 -7
- package/docs/clients.md +18 -6
- package/docs/http-mcp.md +5 -1
- package/docs/releasing.md +3 -1
- package/package.json +1 -1
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 {
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
*
|
|
79
|
-
*
|
|
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 `(
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
if (
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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.
|
|
118
|
-
*
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
}
|
|
136
|
-
if (
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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 = "";
|