@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/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
- return decodeFileText(buffer.subarray(0, offset), decodeUtf16Bom);
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
- function delay(ms) {
645
- return new Promise((resolve) => setTimeout(resolve, ms));
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 claim is a sub-second read-modify-write; a claim lock older than this
24
- // belongs to a crashed holder, not a live claimer, so it can be reaped.
25
- const CLAIM_LOCK_STALE_MS = 60_000;
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). Serialize the
165
- // whole read-modify-write behind an O_EXCL lock file so exactly one claim
166
- // wins; a concurrent claimer sees the lock (or the already-claimed status
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 await this.writeClaimedTask(taskId, task, claimedBy);
200
- }
201
- finally {
202
- await lockHandle.close().catch(() => undefined);
203
- await rm(lockPath, { force: true }).catch(() => undefined);
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
- await this.assertArtifactParentDirectory(path.dirname(artifactPath));
762
- return readVerifiedUtf8File(artifactPath, () => this.assertArtifactTargetInside(artifactPath), {
763
- maxBytes: options.maxBytes ?? MAX_BRIDGE_ARTIFACT_READ_BYTES
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
- /** Read one line with the terminal back in cooked mode, so editing works. */
140
- async function askLine(io, question) {
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.question(question, (value) => settle(value));
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(io);
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(io);
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(io);
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
- io.write("\nNo recent conversations were readable. Start a new chat instead.\n");
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(io);
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
- io.write("\nNo projects are visible in the ChatGPT sidebar.\n");
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(io);
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
- const inside = conversationsInProject((await conversationsPromise) ?? [], projects[chosen].id || undefined);
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(io);
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(io);
371
+ return cancel();
300
372
  if (!projectName)
301
- return cancel(io);
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(io);
314
- if (prompt.length === 0) {
315
- io.write("Nothing to ask.\n");
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(io);
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({ elapsedMs: now() - startedAt, budgetMs, label, tick, width: Math.min(28, terminalWidth() - 52) }));
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).slice(0, 60);
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(" ");