@youdie006/prodex 0.40.50 → 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/chatgpt-browser.js +14 -3
- package/dist/cli-args.js +28 -1
- package/dist/cli-ledger.js +3 -8
- package/dist/cli-pro.js +40 -10
- package/dist/cli.js +66 -21
- 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 +11 -0
- package/docs/http-mcp.md +3 -1
- package/docs/releasing.md +38 -0
- package/package.json +1 -1
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(" ");
|
package/dist/tui.js
CHANGED
|
@@ -120,10 +120,13 @@ export function projectsWithIdsExpression() {
|
|
|
120
120
|
return out;
|
|
121
121
|
})()`;
|
|
122
122
|
}
|
|
123
|
-
/**
|
|
123
|
+
/**
|
|
124
|
+
* The conversations that live inside a project. A project whose id is unknown
|
|
125
|
+
* claims none: returning all of them listed unrelated chats under its name.
|
|
126
|
+
*/
|
|
124
127
|
export function conversationsInProject(conversations, projectId) {
|
|
125
128
|
if (!projectId)
|
|
126
|
-
return
|
|
129
|
+
return [];
|
|
127
130
|
return conversations.filter((conversation) => conversation.gizmoId === projectId);
|
|
128
131
|
}
|
|
129
132
|
/**
|
|
@@ -358,12 +361,26 @@ export function renderProgressBar(input) {
|
|
|
358
361
|
// Saying how to abort belongs on the waiting line itself: a consult can run
|
|
359
362
|
// for many minutes and the only other thing on screen is the bar.
|
|
360
363
|
const stop = "ctrl-c to stop";
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
364
|
+
let compose;
|
|
365
|
+
if (!input.budgetMs || input.budgetMs <= 0) {
|
|
366
|
+
compose = (label) => `${spinner} ${label} ${elapsed} ${stop}`;
|
|
367
|
+
}
|
|
368
|
+
else {
|
|
369
|
+
const width = Math.max(4, input.width ?? 28);
|
|
370
|
+
const ratio = input.elapsedMs / input.budgetMs;
|
|
371
|
+
const filled = Math.min(width, Math.round(Math.min(ratio, 1) * width));
|
|
372
|
+
const bar = `${"#".repeat(filled)}${"-".repeat(width - filled)}`;
|
|
373
|
+
const over = ratio > 1 ? " over budget" : "";
|
|
374
|
+
const budget = formatElapsed(input.budgetMs);
|
|
375
|
+
compose = (label) => `${spinner} [${bar}] ${elapsed} / ${budget} ${label}${over} ${stop}`;
|
|
376
|
+
}
|
|
377
|
+
const line = compose(input.label);
|
|
378
|
+
if (input.columns === undefined || displayWidth(line) < input.columns)
|
|
379
|
+
return line;
|
|
380
|
+
// The line is redrawn in place, and one that wraps leaves a row behind on
|
|
381
|
+
// every redraw (found 2026-11-09: 102 columns at 80, a new row every 250
|
|
382
|
+
// ms). The label gives way first; a terminal too narrow even for the rest
|
|
383
|
+
// gets the line cut.
|
|
384
|
+
const room = input.columns - 1 - displayWidth(compose(""));
|
|
385
|
+
return truncateToWidth(compose(room > 0 ? truncateToWidth(input.label, room) : ""), input.columns - 1);
|
|
369
386
|
}
|
package/docs/clients.md
CHANGED
|
@@ -91,6 +91,17 @@ ChatGPT is still generating. Recover that marked request instead of resending it
|
|
|
91
91
|
The separate `prodex start` HTTP server runs in the foreground and also needs its
|
|
92
92
|
own process kept alive; neither command installs a background service.
|
|
93
93
|
|
|
94
|
+
Cancelling a `pro_consult` (Esc in the client, or the client's own tool timeout,
|
|
95
|
+
both of which send `notifications/cancelled`) stops it while its prompt has not been
|
|
96
|
+
submitted: nothing is sent, the consult is recorded as blocked with
|
|
97
|
+
`consult_cancelled`, and a follow-up spends none of its budget. After the prompt is
|
|
98
|
+
submitted, cancelling changes nothing on prodex's side: the answer is still waited
|
|
99
|
+
for and saved to the consult's record.
|
|
100
|
+
|
|
101
|
+
One stdio message may be up to 128 MiB, which covers the largest call any tool
|
|
102
|
+
accepts. A larger one ends the server with exit code 1 and a one-line reason on
|
|
103
|
+
stderr, so the client sees the server go away instead of waiting on it.
|
|
104
|
+
|
|
94
105
|
## Claude Code
|
|
95
106
|
|
|
96
107
|
See [claude.md](claude.md), or:
|
package/docs/http-mcp.md
CHANGED
|
@@ -19,6 +19,7 @@ Typical use:
|
|
|
19
19
|
|
|
20
20
|
The server is personal and local-first. Do not expose it as a shared service.
|
|
21
21
|
HTTP MCP requests are bounded: malformed JSON returns `400`, and request bodies over 1 MiB are rejected with `413`.
|
|
22
|
+
Sessions are bounded too. A session with no request for 30 minutes and no stream open is closed, and at most 100 sessions are kept: initializing one more closes the least recently used. A request carrying a closed session's `Mcp-Session-Id` gets `404`, as an unknown id does, and MCP clients answer that by initializing a new session.
|
|
22
23
|
|
|
23
24
|
## Build And Prepare
|
|
24
25
|
|
|
@@ -193,8 +194,9 @@ The HTTP server exposes the same tool catalog as stdio MCP:
|
|
|
193
194
|
Writes are deliberately gated and require a git worktree with a committed HEAD:
|
|
194
195
|
|
|
195
196
|
1. `repo_write_file_dry_run` previews replacing an existing repo-relative text file. It records the diff, git HEAD, preimage hash, and a replacement-text artifact under `.bridge/artifacts/repo-writes/`.
|
|
197
|
+
The file keeps its CRLF line endings, UTF-8 BOM and final newline when the new content leaves them out, and apply keeps its file mode, so an edited script stays executable. A file that is not UTF-8 text (EUC-KR, Latin-1, UTF-16) is refused rather than re-encoded.
|
|
196
198
|
2. `repo_write_file_apply` applies that exact receipt only if git HEAD, file path, preimage hash, and artifact content still match.
|
|
197
|
-
3. `repo_stage_reviewed_paths` stages only files backed by matching applied write receipts.
|
|
199
|
+
3. `repo_stage_reviewed_paths` stages only files backed by matching applied write receipts. Their paths reach git literally, so a name such as `pages/[id].tsx` stages that file and nothing it would match as a pattern.
|
|
198
200
|
|
|
199
201
|
There is no direct ungated write tool and no shell execution tool.
|
|
200
202
|
|