@youdie006/prodex 0.40.49 → 0.40.51
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 +6 -3
- package/dist/bundle.js +3 -1
- package/dist/chatgpt-browser.js +32 -7
- package/dist/cli-args.js +28 -1
- package/dist/cli-ledger.js +3 -8
- package/dist/cli-pro.js +65 -20
- package/dist/cli.js +66 -21
- package/dist/followup-budget.js +24 -2
- package/dist/http-mcp.js +108 -14
- package/dist/mcp.js +86 -39
- package/dist/repo-write.js +25 -6
- package/dist/repo.js +59 -27
- package/dist/safe-file.js +41 -7
- package/dist/store.js +102 -63
- package/dist/tui-run.js +111 -37
- package/dist/tui.js +27 -10
- package/docs/clients.md +20 -4
- package/docs/http-mcp.md +3 -1
- package/docs/releasing.md +139 -0
- package/package.json +1 -1
package/dist/safe-file.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isUtf8 } from "node:buffer";
|
|
1
2
|
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { constants, readFileSync } from "node:fs";
|
|
3
4
|
import { chmod, link, lstat, mkdir, open, realpath, rename, rm } from "node:fs/promises";
|
|
@@ -20,6 +21,8 @@ export async function withCrossProcessFileLock(filePath, options, fn) {
|
|
|
20
21
|
let retriedMissingAtDeadline = false;
|
|
21
22
|
let lease;
|
|
22
23
|
for (;;) {
|
|
24
|
+
if (options.signal?.aborted)
|
|
25
|
+
throw options.abortedError?.() ?? new Error("Stopped waiting for the lock: the caller cancelled");
|
|
23
26
|
lease = await tryAcquireFileLock(filePath, parentSnapshot);
|
|
24
27
|
if (lease)
|
|
25
28
|
break;
|
|
@@ -49,7 +52,7 @@ export async function withCrossProcessFileLock(filePath, options, fn) {
|
|
|
49
52
|
waited = true;
|
|
50
53
|
options.onWait?.(snapshot.holder);
|
|
51
54
|
}
|
|
52
|
-
await delay(Math.min(retryMs, Math.max(1, deadline - Date.now())));
|
|
55
|
+
await delay(Math.min(retryMs, Math.max(1, deadline - Date.now())), options.signal);
|
|
53
56
|
}
|
|
54
57
|
try {
|
|
55
58
|
return await fn();
|
|
@@ -58,6 +61,18 @@ export async function withCrossProcessFileLock(filePath, options, fn) {
|
|
|
58
61
|
await releaseFileLockLease(filePath, lease, parentSnapshot);
|
|
59
62
|
}
|
|
60
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* A file whose bytes are not UTF-8. Read as UTF-8, such bytes become U+FFFD,
|
|
66
|
+
* and writing the text back destroys them: measured 2026-11-09, an EUC-KR
|
|
67
|
+
* "한글" line came back as replacement characters through repo writes while
|
|
68
|
+
* the diff showed the line as unchanged. Writers ask for it to be refused.
|
|
69
|
+
*/
|
|
70
|
+
export class NotUtf8Error extends Error {
|
|
71
|
+
constructor() {
|
|
72
|
+
super("Target file is not UTF-8 text");
|
|
73
|
+
this.name = "NotUtf8Error";
|
|
74
|
+
}
|
|
75
|
+
}
|
|
61
76
|
export async function readVerifiedUtf8File(filePath, validate, options = {}) {
|
|
62
77
|
await validate();
|
|
63
78
|
const parentSnapshot = await captureParentSnapshot(filePath);
|
|
@@ -72,7 +87,7 @@ export async function readVerifiedUtf8File(filePath, validate, options = {}) {
|
|
|
72
87
|
if (options.maxBytes !== undefined && stat.size > options.maxBytes) {
|
|
73
88
|
throw new Error(`Target file is too large (${stat.size} bytes)`);
|
|
74
89
|
}
|
|
75
|
-
const content = await readHandleUtf8(handle, filePath, options.maxBytes, options.decodeUtf16Bom === true);
|
|
90
|
+
const content = await readHandleUtf8(handle, filePath, options.maxBytes, options.decodeUtf16Bom === true, options.requireUtf8 === true);
|
|
76
91
|
if (options.mode !== undefined) {
|
|
77
92
|
await testHooks.beforeChmod?.(filePath, "read");
|
|
78
93
|
await assertSafeOpenFile(handle, filePath, "read");
|
|
@@ -127,19 +142,24 @@ export async function replaceVerifiedUtf8File(filePath, content, validate, verif
|
|
|
127
142
|
const parentSnapshot = await captureParentSnapshot(filePath);
|
|
128
143
|
await testHooks.beforeOpen?.(filePath, "write");
|
|
129
144
|
const handle = await openStableNoFollow(filePath, constants.O_RDWR, "write", parentSnapshot, options.mode);
|
|
145
|
+
// The replacement is a new file renamed into place, so it takes the mode of
|
|
146
|
+
// the file it replaces unless one is given: created with the default mode,
|
|
147
|
+
// an edited script lost its executable bit (found 2026-11-09).
|
|
148
|
+
let mode = options.mode;
|
|
130
149
|
try {
|
|
131
150
|
const currentContent = await readHandleUtf8(handle, filePath, options.maxBytes);
|
|
132
151
|
await verifyCurrentContent(currentContent);
|
|
133
152
|
await testHooks.beforeReplace?.(filePath);
|
|
134
153
|
const latestContent = await readHandleUtf8(handle, filePath, options.maxBytes);
|
|
135
154
|
await verifyCurrentContent(latestContent);
|
|
155
|
+
mode ??= (await handle.stat()).mode & 0o777;
|
|
136
156
|
await testHooks.beforeWrite?.(filePath, "write");
|
|
137
157
|
}
|
|
138
158
|
finally {
|
|
139
159
|
await handle.close();
|
|
140
160
|
}
|
|
141
161
|
// Windows cannot replace some files while their verification handle is open.
|
|
142
|
-
await replaceByVerifiedTempFile(filePath, content, validate, options, {
|
|
162
|
+
await replaceByVerifiedTempFile(filePath, content, validate, { ...options, mode }, {
|
|
143
163
|
beforeOpenAlreadyRan: true,
|
|
144
164
|
beforeWriteAlreadyRan: true,
|
|
145
165
|
parentSnapshot,
|
|
@@ -186,7 +206,7 @@ async function replaceByVerifiedTempFile(filePath, content, validate, options =
|
|
|
186
206
|
throw error;
|
|
187
207
|
}
|
|
188
208
|
}
|
|
189
|
-
async function readHandleUtf8(handle, filePath, maxBytes, decodeUtf16Bom = false) {
|
|
209
|
+
async function readHandleUtf8(handle, filePath, maxBytes, decodeUtf16Bom = false, requireUtf8 = false) {
|
|
190
210
|
await assertOpenFileMatchesPath(handle, filePath, "read");
|
|
191
211
|
const stat = await handle.stat();
|
|
192
212
|
if (!stat.isFile()) {
|
|
@@ -207,7 +227,10 @@ async function readHandleUtf8(handle, filePath, maxBytes, decodeUtf16Bom = false
|
|
|
207
227
|
throw new Error(`Target file is too large (${offset} bytes)`);
|
|
208
228
|
}
|
|
209
229
|
}
|
|
210
|
-
|
|
230
|
+
const bytes = buffer.subarray(0, offset);
|
|
231
|
+
if (requireUtf8 && !isUtf8(bytes))
|
|
232
|
+
throw new NotUtf8Error();
|
|
233
|
+
return decodeFileText(bytes, decodeUtf16Bom);
|
|
211
234
|
}
|
|
212
235
|
/**
|
|
213
236
|
* UTF-8, or UTF-16 when asked and the file starts with its BOM. Windows
|
|
@@ -641,8 +664,19 @@ function linuxProcessStartTicks(pid) {
|
|
|
641
664
|
return undefined;
|
|
642
665
|
}
|
|
643
666
|
}
|
|
644
|
-
|
|
645
|
-
|
|
667
|
+
/** Resolves after ms, or as soon as signal aborts. */
|
|
668
|
+
function delay(ms, signal) {
|
|
669
|
+
if (!signal)
|
|
670
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
671
|
+
return new Promise((resolve) => {
|
|
672
|
+
const done = () => {
|
|
673
|
+
clearTimeout(timer);
|
|
674
|
+
signal.removeEventListener("abort", done);
|
|
675
|
+
resolve();
|
|
676
|
+
};
|
|
677
|
+
const timer = setTimeout(done, ms);
|
|
678
|
+
signal.addEventListener("abort", done, { once: true });
|
|
679
|
+
});
|
|
646
680
|
}
|
|
647
681
|
function procFdPath(fd) {
|
|
648
682
|
return `/proc/self/fd/${fd}`;
|
package/dist/store.js
CHANGED
|
@@ -9,7 +9,7 @@ import path from "node:path";
|
|
|
9
9
|
import { spawn } from "node:child_process";
|
|
10
10
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
11
11
|
import { assertRepoRelativePath } from "./repo.js";
|
|
12
|
-
import { readVerifiedUtf8File, writeVerifiedUtf8File } from "./safe-file.js";
|
|
12
|
+
import { readVerifiedUtf8File, withCrossProcessFileLock, writeVerifiedUtf8File } from "./safe-file.js";
|
|
13
13
|
import { makeBridgeId, nowIso, ReceiptSchema, ResultSchema, SCHEMA_VERSION, SessionSchema, TaskSchema } from "./schema.js";
|
|
14
14
|
let storeTestHooks = {};
|
|
15
15
|
export function setBridgeStoreTestHooks(hooks) {
|
|
@@ -20,23 +20,10 @@ const SESSION_ID_PATTERN = /^sess_\d{8}_\d{6}_[a-z0-9-]+$/;
|
|
|
20
20
|
const RECEIPT_ID_PATTERN = /^receipt_\d{8}_\d{6}_[a-z0-9-]+$/;
|
|
21
21
|
const BRIDGE_DIRECTORY_MODE = 0o700;
|
|
22
22
|
const BRIDGE_FILE_MODE = 0o600;
|
|
23
|
-
// A
|
|
24
|
-
//
|
|
25
|
-
const
|
|
23
|
+
// A task update is a sub-second read-modify-write; one waiting longer than
|
|
24
|
+
// this for another is told to try again.
|
|
25
|
+
const TASK_UPDATE_LOCK_WAIT_MS = 10_000;
|
|
26
26
|
const RECEIPT_INTEGRITY_KEY_BYTES = 32;
|
|
27
|
-
async function claimLockIsStale(lockPath) {
|
|
28
|
-
try {
|
|
29
|
-
const info = await stat(lockPath);
|
|
30
|
-
return Date.now() - info.mtimeMs > CLAIM_LOCK_STALE_MS;
|
|
31
|
-
}
|
|
32
|
-
catch (error) {
|
|
33
|
-
// Vanished between the EEXIST and the stat (the holder just released it):
|
|
34
|
-
// treat as free so the retry can grab it.
|
|
35
|
-
if (isErrorCode(error, "ENOENT"))
|
|
36
|
-
return true;
|
|
37
|
-
throw error;
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
27
|
const FETCHABLE_RESULT_ARTIFACT_PREFIXES = [".bridge/artifacts/pro-consults/", ".bridge/artifacts/results/"];
|
|
41
28
|
export const MAX_FETCHABLE_RESULT_ARTIFACT_BYTES = 100_000;
|
|
42
29
|
const MAX_BRIDGE_ARTIFACT_READ_BYTES = 1_000_000;
|
|
@@ -161,47 +148,33 @@ export class BridgeStore {
|
|
|
161
148
|
}
|
|
162
149
|
async claimTask(taskId, claimedBy) {
|
|
163
150
|
// Claiming is check-then-write, so two agents that both read "new" would
|
|
164
|
-
// otherwise both write "claimed" (a silent double-claim).
|
|
165
|
-
//
|
|
166
|
-
|
|
167
|
-
// after it releases) and is rejected.
|
|
168
|
-
const lockPath = path.join(this.dir("tasks"), `.${taskId}.claim.lock`);
|
|
169
|
-
let lockHandle;
|
|
170
|
-
try {
|
|
171
|
-
lockHandle = await open(lockPath, "wx", BRIDGE_FILE_MODE);
|
|
172
|
-
}
|
|
173
|
-
catch (error) {
|
|
174
|
-
if (!isErrorCode(error, "EEXIST"))
|
|
175
|
-
throw error;
|
|
176
|
-
// A claim is a fast read-modify-write, so a lock older than the staleness
|
|
177
|
-
// window belongs to a crashed holder (killed before its finally removed
|
|
178
|
-
// it), not a live claimer - otherwise it would wedge this task's claims
|
|
179
|
-
// forever. Reap the stale lock and retry the exclusive create once.
|
|
180
|
-
if (!(await claimLockIsStale(lockPath))) {
|
|
181
|
-
throw new Error(`Task ${taskId} is already being claimed by another process`);
|
|
182
|
-
}
|
|
183
|
-
await rm(lockPath, { force: true }).catch(() => undefined);
|
|
184
|
-
try {
|
|
185
|
-
lockHandle = await open(lockPath, "wx", BRIDGE_FILE_MODE);
|
|
186
|
-
}
|
|
187
|
-
catch (retryError) {
|
|
188
|
-
if (isErrorCode(retryError, "EEXIST")) {
|
|
189
|
-
throw new Error(`Task ${taskId} is already being claimed by another process`);
|
|
190
|
-
}
|
|
191
|
-
throw retryError;
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
try {
|
|
151
|
+
// otherwise both write "claimed" (a silent double-claim). Under the task's
|
|
152
|
+
// update lock exactly one claim wins; the next one finds it claimed.
|
|
153
|
+
return this.withTaskUpdateLock(taskId, async () => {
|
|
195
154
|
const task = await this.getTask(taskId);
|
|
196
155
|
if (task.status !== "new") {
|
|
197
156
|
throw new Error(`Task ${taskId} is ${task.status}, not new`);
|
|
198
157
|
}
|
|
199
|
-
return
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
158
|
+
return this.writeClaimedTask(taskId, task, claimedBy);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Every change to a task record is a read-modify-write, and only claims used
|
|
163
|
+
* to take a lock. Found 2026-11-09: a claim that read "new" while a
|
|
164
|
+
* completion ran wrote "claimed" over it, leaving a claimed task with no
|
|
165
|
+
* result_path next to a done result and its trusted receipt (11 of 30 tasks
|
|
166
|
+
* with real processes), and a claim lock left stale by a crashed claimer let
|
|
167
|
+
* two claimers both win, since each deleted it and made its own (6 of 15
|
|
168
|
+
* rounds). Claims and completions now share one lock per task, whose holder
|
|
169
|
+
* is a live process: a crashed holder's lock is taken over safely.
|
|
170
|
+
*/
|
|
171
|
+
async withTaskUpdateLock(taskId, fn) {
|
|
172
|
+
return withCrossProcessFileLock(path.join(this.dir("tasks"), `.${taskId}.update.lock`), {
|
|
173
|
+
waitMs: TASK_UPDATE_LOCK_WAIT_MS,
|
|
174
|
+
retryMs: 25,
|
|
175
|
+
busyError: (holder) => new Error(`Task ${taskId} is being updated by another process (pid ${holder.pid ?? "unknown"}); try again.`),
|
|
176
|
+
unavailableError: () => new Error(`Task ${taskId} could not be locked for an update; try again.`)
|
|
177
|
+
}, fn);
|
|
205
178
|
}
|
|
206
179
|
async writeClaimedTask(taskId, task, claimedBy) {
|
|
207
180
|
const updated = TaskSchema.parse({
|
|
@@ -231,6 +204,9 @@ export class BridgeStore {
|
|
|
231
204
|
return updated;
|
|
232
205
|
}
|
|
233
206
|
async completeTask(taskId, input) {
|
|
207
|
+
return this.withTaskUpdateLock(taskId, () => this.completeTaskUnderLock(taskId, input));
|
|
208
|
+
}
|
|
209
|
+
async completeTaskUnderLock(taskId, input) {
|
|
234
210
|
const task = await this.getTask(taskId);
|
|
235
211
|
assertFetchableResultArtifacts(input.artifacts ?? []);
|
|
236
212
|
assertDistinctArtifactPaths(input.artifacts ?? []);
|
|
@@ -689,7 +665,7 @@ export class BridgeStore {
|
|
|
689
665
|
}
|
|
690
666
|
throw untrustedResultError(this.root, taskId, `has no locally trusted legacy task_completed receipt to reseal: ${failures[0] ?? "local integrity unavailable"}`);
|
|
691
667
|
}
|
|
692
|
-
async writeArtifactText(relativePath, content) {
|
|
668
|
+
async writeArtifactText(relativePath, content, options = {}) {
|
|
693
669
|
await this.ensure();
|
|
694
670
|
await this.assertBridgeDirIsRealDirectory();
|
|
695
671
|
await this.assertArtifactsDirIsRealDirectory();
|
|
@@ -698,9 +674,41 @@ export class BridgeStore {
|
|
|
698
674
|
await writeVerifiedUtf8File(artifactPath, content, async () => {
|
|
699
675
|
await this.assertArtifactParentDirectory(path.dirname(artifactPath));
|
|
700
676
|
await this.assertArtifactTargetInsideIfExists(artifactPath);
|
|
701
|
-
}, { create: true, mode: BRIDGE_FILE_MODE });
|
|
677
|
+
}, { create: true, mode: BRIDGE_FILE_MODE, ...(options.exclusive ? { exclusive: true } : {}) });
|
|
702
678
|
return this.relativeToRoot(artifactPath);
|
|
703
679
|
}
|
|
680
|
+
/**
|
|
681
|
+
* The files of `tasks complete --artifact path=text`, written only once the
|
|
682
|
+
* completion is known to be possible. They used to be written first, so a
|
|
683
|
+
* completion refused afterwards had already replaced the file: found
|
|
684
|
+
* 2026-11-09, completing a finished task again, or a mistyped task id,
|
|
685
|
+
* overwrote a saved answer, and an open task completed with another task's
|
|
686
|
+
* artifact path replaced that task's answer. A finished task's files are not
|
|
687
|
+
* touched (completeTask then refuses, or accepts the same result again), and
|
|
688
|
+
* a path that already holds other text is refused.
|
|
689
|
+
*/
|
|
690
|
+
async writeResultArtifactTexts(taskId, texts) {
|
|
691
|
+
const task = await this.getTask(taskId);
|
|
692
|
+
const artifacts = texts.map((text) => ({ path: this.relativeToRoot(this.resolveArtifactPath(text.path)), role: "result" }));
|
|
693
|
+
assertFetchableResultArtifacts(artifacts);
|
|
694
|
+
assertDistinctArtifactPaths(artifacts);
|
|
695
|
+
if (task.status === "done" || task.status === "blocked")
|
|
696
|
+
return artifacts;
|
|
697
|
+
for (const [index, text] of texts.entries()) {
|
|
698
|
+
const stored = artifacts[index].path;
|
|
699
|
+
try {
|
|
700
|
+
await this.writeArtifactText(stored, text.content, { exclusive: true });
|
|
701
|
+
}
|
|
702
|
+
catch (error) {
|
|
703
|
+
if (!isErrorCode(error, "EEXIST"))
|
|
704
|
+
throw error;
|
|
705
|
+
if ((await this.readArtifactText(stored)) === text.content)
|
|
706
|
+
continue;
|
|
707
|
+
throw new Error(`${stored} already exists with other content; give this result an artifact path of its own.`);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
return artifacts;
|
|
711
|
+
}
|
|
704
712
|
async hasArtifactText(relativePath) {
|
|
705
713
|
const artifactPath = this.resolveArtifactPath(relativePath);
|
|
706
714
|
try {
|
|
@@ -758,10 +766,21 @@ export class BridgeStore {
|
|
|
758
766
|
await this.assertBridgeDirIsRealDirectory();
|
|
759
767
|
await this.assertArtifactsDirIsRealDirectory();
|
|
760
768
|
const artifactPath = this.resolveArtifactPath(relativePath);
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
769
|
+
try {
|
|
770
|
+
await this.assertArtifactParentDirectory(path.dirname(artifactPath));
|
|
771
|
+
return await readVerifiedUtf8File(artifactPath, () => this.assertArtifactTargetInside(artifactPath), {
|
|
772
|
+
maxBytes: options.maxBytes ?? MAX_BRIDGE_ARTIFACT_READ_BYTES
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
catch (error) {
|
|
776
|
+
// The raw error named the machine's absolute path, often of the parent
|
|
777
|
+
// directory rather than the file, and went out to MCP clients as it was
|
|
778
|
+
// (found 2026-11-09). The code stays ENOENT for callers that test it.
|
|
779
|
+
if (isErrorCode(error, "ENOENT")) {
|
|
780
|
+
throw Object.assign(new Error(`Artifact not found: ${this.relativeToRoot(artifactPath)}`), { code: "ENOENT" });
|
|
781
|
+
}
|
|
782
|
+
throw error;
|
|
783
|
+
}
|
|
765
784
|
}
|
|
766
785
|
async writeReceipt(input) {
|
|
767
786
|
await this.ensure();
|
|
@@ -889,9 +908,9 @@ export class BridgeStore {
|
|
|
889
908
|
// The key file holds one 32-byte hex key per line: the first line signs new
|
|
890
909
|
// receipts, later lines are legacy keys kept so pre-rotation receipts verify.
|
|
891
910
|
async readReceiptIntegrityKeys() {
|
|
892
|
-
const lines = (await readVerifiedUtf8File(this.receiptIntegrityKeyPath(), () => this.assertReceiptIntegrityKeyTargetSafe({ allowMissing: false }), {
|
|
911
|
+
const lines = (await readReplacedFileText(() => readVerifiedUtf8File(this.receiptIntegrityKeyPath(), () => this.assertReceiptIntegrityKeyTargetSafe({ allowMissing: false }), {
|
|
893
912
|
mode: BRIDGE_FILE_MODE
|
|
894
|
-
}))
|
|
913
|
+
})))
|
|
895
914
|
.split(/\r?\n/)
|
|
896
915
|
.map((line) => line.trim())
|
|
897
916
|
.filter((line) => line.length > 0);
|
|
@@ -1004,7 +1023,7 @@ export class BridgeStore {
|
|
|
1004
1023
|
if (options.cleanupTempHardLinks ?? true) {
|
|
1005
1024
|
await this.cleanupRecordTempHardLinks(kind, filePath);
|
|
1006
1025
|
}
|
|
1007
|
-
const raw = await readVerifiedUtf8File(filePath, () => this.assertRecordTargetInside(kind, filePath));
|
|
1026
|
+
const raw = await readReplacedFileText(() => readVerifiedUtf8File(filePath, () => this.assertRecordTargetInside(kind, filePath)));
|
|
1008
1027
|
try {
|
|
1009
1028
|
return JSON.parse(raw);
|
|
1010
1029
|
}
|
|
@@ -1467,6 +1486,26 @@ function recordLabel(kind) {
|
|
|
1467
1486
|
return "Receipt";
|
|
1468
1487
|
}
|
|
1469
1488
|
}
|
|
1489
|
+
// Records and the receipt key are replaced by renaming a new file over them,
|
|
1490
|
+
// and a read that opened the old file and then found the new one at the path
|
|
1491
|
+
// failed as "Target path changed during read file operation": found
|
|
1492
|
+
// 2026-11-09, 115 of 908 reads failed while another process rewrote a
|
|
1493
|
+
// session, and `tasks list` failed the same way during a consult. Reading
|
|
1494
|
+
// again opens the new file and runs every check again; any other failure,
|
|
1495
|
+
// a symlink swap among them, is not retried.
|
|
1496
|
+
const REPLACED_FILE_READ_ATTEMPTS = 5;
|
|
1497
|
+
export async function readReplacedFileText(read) {
|
|
1498
|
+
for (let attempt = 1;; attempt += 1) {
|
|
1499
|
+
try {
|
|
1500
|
+
return await read();
|
|
1501
|
+
}
|
|
1502
|
+
catch (error) {
|
|
1503
|
+
const replaced = error instanceof Error && error.message === "Target path changed during read file operation";
|
|
1504
|
+
if (!replaced || attempt >= REPLACED_FILE_READ_ATTEMPTS)
|
|
1505
|
+
throw error;
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1470
1509
|
function isErrorCode(error, code) {
|
|
1471
1510
|
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
1472
1511
|
}
|
package/dist/tui-run.js
CHANGED
|
@@ -10,7 +10,7 @@ import readline from "node:readline";
|
|
|
10
10
|
import { renderBanner } from "./banner.js";
|
|
11
11
|
import { shellQuote } from "./cli-args.js";
|
|
12
12
|
import { destinationChoices, effortChoices, SEND_KINDS } from "./tui-flow.js";
|
|
13
|
-
import { consultArgsFromChoices, conversationsInProject, conversationThreadUrl, moveCursor, parseAttachmentLine, progressLabel, renderContextPanel, renderProgressBar, renderSelectList } from "./tui.js";
|
|
13
|
+
import { consultArgsFromChoices, conversationsInProject, conversationThreadUrl, displayWidth, moveCursor, parseAttachmentLine, progressLabel, renderContextPanel, renderProgressBar, renderSelectList } from "./tui.js";
|
|
14
14
|
const ESC = "";
|
|
15
15
|
const CLEAR = `${ESC}[2J${ESC}[H`;
|
|
16
16
|
// The alternate screen buffer: the terminal comes back exactly as it was, so a
|
|
@@ -20,6 +20,15 @@ const ALT_SCREEN_OFF = `${ESC}[?1049l`;
|
|
|
20
20
|
const CLEAR_LINE = `${ESC}[2K\r`;
|
|
21
21
|
const HIDE_CURSOR = `${ESC}[?25l`;
|
|
22
22
|
const SHOW_CURSOR = `${ESC}[?25h`;
|
|
23
|
+
// Bracketed paste: the terminal wraps a paste in ESC[200~ ... ESC[201~, which
|
|
24
|
+
// readline reports as "paste-start" and "paste-end" keys and otherwise ignores.
|
|
25
|
+
const BRACKETED_PASTE_ON = `${ESC}[?2004h`;
|
|
26
|
+
const BRACKETED_PASTE_OFF = `${ESC}[?2004l`;
|
|
27
|
+
// How long an Enter at the prompt waits before it ends the prompt. A terminal
|
|
28
|
+
// that does not mark pastes sends one as typed keys, its line breaks included,
|
|
29
|
+
// and the rest of it follows in the same read or milliseconds later; a person
|
|
30
|
+
// does not type the next key that fast.
|
|
31
|
+
const PASTE_SETTLE_MS = 100;
|
|
23
32
|
/**
|
|
24
33
|
* Keys are queued by one long-lived listener rather than a listener attached
|
|
25
34
|
* per read.
|
|
@@ -136,19 +145,37 @@ function numericChoice(key, length) {
|
|
|
136
145
|
return undefined;
|
|
137
146
|
return digit - 1;
|
|
138
147
|
}
|
|
139
|
-
/**
|
|
140
|
-
|
|
148
|
+
/**
|
|
149
|
+
* Read one answer with the terminal back in line-editing mode, so editing works.
|
|
150
|
+
*
|
|
151
|
+
* `multiline` is for the prompt, which is often pasted. readline ends a line at
|
|
152
|
+
* every line break, so a pasted stack trace used to be sent as its first line
|
|
153
|
+
* and the rest dropped. With it, a paste's line breaks stay in the answer and
|
|
154
|
+
* only an Enter typed on its own ends it.
|
|
155
|
+
*/
|
|
156
|
+
async function askLine(io, question, options = {}) {
|
|
141
157
|
io.input.setRawMode?.(false);
|
|
142
|
-
const rl = readline.createInterface({ input: io.input, output: process.stdout, terminal: true });
|
|
158
|
+
const rl = readline.createInterface({ input: io.input, output: process.stdout, terminal: true, historySize: 0 });
|
|
159
|
+
// Later lines of a prompt sit under its first line, not at the margin.
|
|
160
|
+
const continuation = " ".repeat(displayWidth(question.slice(question.lastIndexOf("\n") + 1)));
|
|
143
161
|
try {
|
|
162
|
+
if (options.multiline)
|
|
163
|
+
io.write(BRACKETED_PASTE_ON);
|
|
144
164
|
const answer = await new Promise((resolve) => {
|
|
165
|
+
const lines = [];
|
|
166
|
+
let pasting = false;
|
|
167
|
+
let previousKey;
|
|
168
|
+
let ending;
|
|
145
169
|
let settled = false;
|
|
146
170
|
function settle(value) {
|
|
147
171
|
if (settled)
|
|
148
172
|
return;
|
|
149
173
|
settled = true;
|
|
174
|
+
clearTimeout(ending);
|
|
150
175
|
rl.off("SIGINT", onInterrupt);
|
|
151
176
|
rl.off("close", onClose);
|
|
177
|
+
rl.off("line", onLine);
|
|
178
|
+
io.input.off("keypress", onKey);
|
|
152
179
|
resolve(value);
|
|
153
180
|
}
|
|
154
181
|
function onInterrupt() {
|
|
@@ -157,14 +184,50 @@ async function askLine(io, question) {
|
|
|
157
184
|
function onClose() {
|
|
158
185
|
settle(undefined);
|
|
159
186
|
}
|
|
187
|
+
// Sees each key before readline does.
|
|
188
|
+
function onKey(_text, key) {
|
|
189
|
+
const name = key?.name;
|
|
190
|
+
// A line feed straight after a carriage return is the same Enter.
|
|
191
|
+
const sameEnter = name === "enter" && previousKey === "return";
|
|
192
|
+
previousKey = name;
|
|
193
|
+
// Input right behind an Enter means the Enter was a line break inside a
|
|
194
|
+
// paste the terminal did not mark, so the prompt goes on.
|
|
195
|
+
if (ending !== undefined && !sameEnter) {
|
|
196
|
+
clearTimeout(ending);
|
|
197
|
+
ending = undefined;
|
|
198
|
+
}
|
|
199
|
+
if (name === "paste-start")
|
|
200
|
+
pasting = true;
|
|
201
|
+
else if (name === "paste-end")
|
|
202
|
+
pasting = false;
|
|
203
|
+
}
|
|
204
|
+
function onLine(line) {
|
|
205
|
+
if (!options.multiline) {
|
|
206
|
+
settle(line);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
lines.push(line);
|
|
210
|
+
rl.setPrompt(continuation);
|
|
211
|
+
rl.prompt();
|
|
212
|
+
if (!pasting)
|
|
213
|
+
ending = setTimeout(() => settle(lines.join("\n")), PASTE_SETTLE_MS);
|
|
214
|
+
}
|
|
160
215
|
rl.once("SIGINT", onInterrupt);
|
|
161
216
|
rl.once("close", onClose);
|
|
162
|
-
rl.
|
|
217
|
+
rl.on("line", onLine);
|
|
218
|
+
if (options.multiline)
|
|
219
|
+
io.input.prependListener("keypress", onKey);
|
|
220
|
+
rl.setPrompt(question);
|
|
221
|
+
rl.prompt();
|
|
163
222
|
});
|
|
164
223
|
return answer?.trim();
|
|
165
224
|
}
|
|
166
225
|
finally {
|
|
167
226
|
rl.close();
|
|
227
|
+
// Turned off on every way out, ctrl-c and end of input included: left on,
|
|
228
|
+
// the shell would receive the markers around every later paste.
|
|
229
|
+
if (options.multiline)
|
|
230
|
+
io.write(BRACKETED_PASTE_OFF);
|
|
168
231
|
// Closing the line reader detaches the keypress plumbing and pauses the
|
|
169
232
|
// stream, so the next picker would sit there ignoring every key. Re-arm both.
|
|
170
233
|
io.input.setRawMode?.(true);
|
|
@@ -196,6 +259,18 @@ export async function runInteractiveConsult(io, deps) {
|
|
|
196
259
|
keys = new KeyQueue(io.input);
|
|
197
260
|
io.write(ALT_SCREEN_ON + HIDE_CURSOR);
|
|
198
261
|
let header = "";
|
|
262
|
+
// Why the picker stopped is written once the alternate screen is gone.
|
|
263
|
+
// Written while it was up, the reason vanished with it and the picker exited
|
|
264
|
+
// with nothing on screen.
|
|
265
|
+
let farewell = "";
|
|
266
|
+
const leave = (message, code) => {
|
|
267
|
+
farewell = message;
|
|
268
|
+
return code;
|
|
269
|
+
};
|
|
270
|
+
const cancel = () => leave("Cancelled.\n", 130);
|
|
271
|
+
// Leaving it twice put the cursor back where the picker opened, and the
|
|
272
|
+
// shell prompt was drawn over the echoed command (found 2026-11-09).
|
|
273
|
+
let onAltScreen = true;
|
|
199
274
|
try {
|
|
200
275
|
// The logo is the program saying which program it is; the panel says what
|
|
201
276
|
// this send will use. Both belong above the first question, not nowhere.
|
|
@@ -216,7 +291,7 @@ export async function runInteractiveConsult(io, deps) {
|
|
|
216
291
|
options: SEND_KINDS.map((kind) => ({ label: kind.label, ...(kind.hint ? { hint: kind.hint } : {}) }))
|
|
217
292
|
}, banner);
|
|
218
293
|
if (kindChoice === undefined)
|
|
219
|
-
return cancel(
|
|
294
|
+
return cancel();
|
|
220
295
|
const tools = SEND_KINDS[kindChoice].tools;
|
|
221
296
|
// Only an ordinary chat has a reasoning level to choose: the tool kinds
|
|
222
297
|
// bring their own pipeline, and an effort would deselect Pro under them.
|
|
@@ -231,7 +306,7 @@ export async function runInteractiveConsult(io, deps) {
|
|
|
231
306
|
options: efforts.map((entry) => ({ label: entry.label, ...(entry.hint ? { hint: entry.hint } : {}) }))
|
|
232
307
|
}, banner);
|
|
233
308
|
if (chosen === undefined)
|
|
234
|
-
return cancel(
|
|
309
|
+
return cancel();
|
|
235
310
|
effort = efforts[chosen].effort;
|
|
236
311
|
}
|
|
237
312
|
const rows = (await contextPromise) ?? [];
|
|
@@ -247,37 +322,34 @@ export async function runInteractiveConsult(io, deps) {
|
|
|
247
322
|
options: destinations.map((entry) => ({ label: entry.label, ...(entry.hint ? { hint: entry.hint } : {}) }))
|
|
248
323
|
}, header);
|
|
249
324
|
if (destinationChoice === undefined)
|
|
250
|
-
return cancel(
|
|
325
|
+
return cancel();
|
|
251
326
|
const destination = destinations[destinationChoice].id;
|
|
252
327
|
let projectMode = "current";
|
|
253
328
|
let projectName;
|
|
254
329
|
let targetUrl;
|
|
255
330
|
if (destination === "continue") {
|
|
256
331
|
const conversations = (await conversationsPromise) ?? [];
|
|
257
|
-
if (conversations.length === 0)
|
|
258
|
-
|
|
259
|
-
return 1;
|
|
260
|
-
}
|
|
332
|
+
if (conversations.length === 0)
|
|
333
|
+
return leave("No recent conversations were readable. Start a new chat instead.\n", 1);
|
|
261
334
|
const chosen = await pick(io, { title: "Which conversation?", options: conversations.map((entry) => ({ label: entry.title })) }, header);
|
|
262
335
|
if (chosen === undefined)
|
|
263
|
-
return cancel(
|
|
336
|
+
return cancel();
|
|
264
337
|
targetUrl = conversationThreadUrl(conversations[chosen].id);
|
|
265
338
|
}
|
|
266
339
|
else if (destination === "project") {
|
|
267
340
|
const detailed = (await deps.listProjectsWithIds?.()) ?? [];
|
|
268
341
|
const projects = detailed.length > 0 ? detailed : (await deps.listProjects()).map((name) => ({ id: "", name }));
|
|
269
|
-
if (projects.length === 0)
|
|
270
|
-
|
|
271
|
-
return 1;
|
|
272
|
-
}
|
|
342
|
+
if (projects.length === 0)
|
|
343
|
+
return leave("No projects are visible in the ChatGPT sidebar.\n", 1);
|
|
273
344
|
const chosen = await pick(io, { title: "Which project?", options: projects.map((entry) => ({ label: entry.name })) }, header);
|
|
274
345
|
if (chosen === undefined)
|
|
275
|
-
return cancel(
|
|
346
|
+
return cancel();
|
|
276
347
|
projectMode = "existing";
|
|
277
348
|
projectName = projects[chosen].name;
|
|
278
349
|
// Entering a project usually means going back to something in it, so
|
|
279
|
-
// offer its chats before starting yet another one.
|
|
280
|
-
|
|
350
|
+
// offer its chats before starting yet another one. A project listed by
|
|
351
|
+
// name alone has no id to match them by, so it goes straight to a new chat.
|
|
352
|
+
const inside = conversationsInProject((await conversationsPromise) ?? [], projects[chosen].id);
|
|
281
353
|
if (inside.length > 0) {
|
|
282
354
|
const options = [
|
|
283
355
|
{ label: "Start a new chat in this project" },
|
|
@@ -285,7 +357,7 @@ export async function runInteractiveConsult(io, deps) {
|
|
|
285
357
|
];
|
|
286
358
|
const picked = await pick(io, { title: `${projects[chosen].name}`, options }, header);
|
|
287
359
|
if (picked === undefined)
|
|
288
|
-
return cancel(
|
|
360
|
+
return cancel();
|
|
289
361
|
if (picked > 0) {
|
|
290
362
|
targetUrl = conversationThreadUrl(inside[picked - 1].id);
|
|
291
363
|
projectMode = "current";
|
|
@@ -296,9 +368,9 @@ export async function runInteractiveConsult(io, deps) {
|
|
|
296
368
|
else if (destination === "project-new") {
|
|
297
369
|
projectName = await askLine(io, `${CLEAR}${header}New project\n\n name: `);
|
|
298
370
|
if (projectName === undefined)
|
|
299
|
-
return cancel(
|
|
371
|
+
return cancel();
|
|
300
372
|
if (!projectName)
|
|
301
|
-
return cancel(
|
|
373
|
+
return cancel();
|
|
302
374
|
projectMode = "new";
|
|
303
375
|
}
|
|
304
376
|
else if (destination === "no-project") {
|
|
@@ -307,14 +379,12 @@ export async function runInteractiveConsult(io, deps) {
|
|
|
307
379
|
// The prompt comes last: what you type depends on what you just decided.
|
|
308
380
|
io.write(CLEAR + header + SHOW_CURSOR);
|
|
309
381
|
const kindLabel = SEND_KINDS[kindChoice].label;
|
|
310
|
-
const prompt = await askLine(io, `${kindLabel} Step ${steps} of ${steps}\n\n prompt:
|
|
382
|
+
const prompt = await askLine(io, `${kindLabel} Step ${steps} of ${steps}\n\n prompt: `, { multiline: true });
|
|
311
383
|
io.write(HIDE_CURSOR);
|
|
312
384
|
if (prompt === undefined)
|
|
313
|
-
return cancel(
|
|
314
|
-
if (prompt.length === 0)
|
|
315
|
-
|
|
316
|
-
return 1;
|
|
317
|
-
}
|
|
385
|
+
return cancel();
|
|
386
|
+
if (prompt.length === 0)
|
|
387
|
+
return leave("Nothing to ask.\n", 1);
|
|
318
388
|
// Uploading a file is the only way ChatGPT can open a pdf, pptx, xlsx or
|
|
319
389
|
// image, and the picker had no way to say so. Asked after the prompt, and
|
|
320
390
|
// skipped by pressing enter, so the common case costs one keystroke.
|
|
@@ -322,7 +392,7 @@ export async function runInteractiveConsult(io, deps) {
|
|
|
322
392
|
const attachmentLine = await askLine(io, "\n attach files? repo-relative paths, space separated, enter to skip\n files: ");
|
|
323
393
|
io.write(HIDE_CURSOR);
|
|
324
394
|
if (attachmentLine === undefined)
|
|
325
|
-
return cancel(
|
|
395
|
+
return cancel();
|
|
326
396
|
const attachments = parseAttachmentLine(attachmentLine);
|
|
327
397
|
const choices = {
|
|
328
398
|
prompt,
|
|
@@ -338,6 +408,7 @@ export async function runInteractiveConsult(io, deps) {
|
|
|
338
408
|
// Leave the alternate screen before the send: the answer, the receipt id
|
|
339
409
|
// and any blocker belong in the scrollback the user keeps.
|
|
340
410
|
io.write(ALT_SCREEN_OFF + SHOW_CURSOR);
|
|
411
|
+
onAltScreen = false;
|
|
341
412
|
// Hand the terminal back too. Raw mode is what let the pickers read single
|
|
342
413
|
// keys, and it also turns off the terminal's own ctrl-c: the key arrives as
|
|
343
414
|
// a keypress nothing is waiting for. The progress bar promises "ctrl-c to
|
|
@@ -352,11 +423,18 @@ export async function runInteractiveConsult(io, deps) {
|
|
|
352
423
|
const timer = setInterval(() => {
|
|
353
424
|
tick += 1;
|
|
354
425
|
io.write(CLEAR_LINE +
|
|
355
|
-
renderProgressBar({
|
|
426
|
+
renderProgressBar({
|
|
427
|
+
elapsedMs: now() - startedAt,
|
|
428
|
+
budgetMs,
|
|
429
|
+
label,
|
|
430
|
+
tick,
|
|
431
|
+
width: Math.min(28, terminalWidth() - 52),
|
|
432
|
+
columns: process.stdout.columns ?? 100
|
|
433
|
+
}));
|
|
356
434
|
}, 250);
|
|
357
435
|
try {
|
|
358
436
|
const code = await deps.runConsult(args, (line) => {
|
|
359
|
-
label = progressLabel(line)
|
|
437
|
+
label = progressLabel(line);
|
|
360
438
|
});
|
|
361
439
|
clearInterval(timer);
|
|
362
440
|
io.write(CLEAR_LINE);
|
|
@@ -371,15 +449,11 @@ export async function runInteractiveConsult(io, deps) {
|
|
|
371
449
|
finally {
|
|
372
450
|
keys?.dispose();
|
|
373
451
|
keys = undefined;
|
|
374
|
-
io.write(ALT_SCREEN_OFF + SHOW_CURSOR);
|
|
452
|
+
io.write((onAltScreen ? ALT_SCREEN_OFF : "") + SHOW_CURSOR + farewell);
|
|
375
453
|
io.input.setRawMode?.(false);
|
|
376
454
|
io.input.pause();
|
|
377
455
|
}
|
|
378
456
|
}
|
|
379
|
-
function cancel(io) {
|
|
380
|
-
io.write("\nCancelled.\n");
|
|
381
|
-
return 130;
|
|
382
|
-
}
|
|
383
457
|
/** Quote only what a shell would need quoted, so the echo can be pasted. */
|
|
384
458
|
export function formatCommand(args) {
|
|
385
459
|
return args.map((arg) => shellQuote(arg)).join(" ");
|