@youdie006/prodex 0.40.6 → 0.40.8

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.
@@ -24,14 +24,18 @@ export function issueAreaLabel(code) {
24
24
  /**
25
25
  * Build the report. Only the failure travels: never the prompt, the answer, or
26
26
  * the summary, because a public issue must not become where a private consult
27
- * leaks. Everything included here is either environment or blocker metadata.
27
+ * leaks. Blocker prose can contain answers, paths and thread URLs, so it stays
28
+ * local too. Public reports carry a code and a fixed diagnostic summary.
28
29
  */
29
30
  export function buildIssueReport(consult, environment) {
30
31
  if (consult.status !== "blocked" || !consult.blocker) {
31
32
  throw new Error(`${consult.task_id} is not a failure (status ${consult.status}), so there is nothing to report.`);
32
33
  }
33
- const code = (consult.blocker.code ?? "unknown").trim();
34
- const message = (consult.blocker.message ?? "").trim();
34
+ const rawCode = (consult.blocker.code ?? "unknown").trim();
35
+ const code = /^[a-z][a-z0-9_]{0,79}$/.test(rawCode) ? rawCode : "unknown";
36
+ const message = code === "smoke_token_mismatch"
37
+ ? "The browser response did not match the smoke-test token."
38
+ : "Consult blocked; detailed diagnostics are kept in the local receipt.";
35
39
  const area = issueAreaLabel(code);
36
40
  const body = [
37
41
  "A consult was blocked. Filed from its receipt, so the prompt and the answer are not included.",
@@ -45,7 +49,8 @@ export function buildIssueReport(consult, environment) {
45
49
  `| platform | ${environment.platform} |`,
46
50
  `| node | ${environment.nodeVersion} |`,
47
51
  "",
48
- ...(consult.blocker.next_step ? ["What it told the caller to do:", "", `> ${consult.blocker.next_step}`, ""] : []),
52
+ "Private error details and recovery instructions are omitted. Review the local receipt before sharing more context.",
53
+ "",
49
54
  "Receipt (local, not attached): " + consult.task_id
50
55
  ].join("\n");
51
56
  return {
package/dist/mcp-tools.js CHANGED
@@ -4,6 +4,33 @@ import { applyRepoWriteDryRun, createRepoWriteDryRun, stageReviewedPaths } from
4
4
  export const MAX_MCP_BRIDGE_TEXT_BYTES = MAX_FETCHABLE_RESULT_ARTIFACT_BYTES;
5
5
  export const MAX_MCP_SHORT_TEXT_BYTES = 10_000;
6
6
  const MAX_MCP_LIST_ITEMS = 100;
7
+ function redactContextText(text, privateValues = []) {
8
+ let redacted = text;
9
+ for (const value of privateValues) {
10
+ if (value)
11
+ redacted = redacted.replaceAll(value, "[redacted]");
12
+ }
13
+ return redacted.replace(/https?:\/\/(?:chatgpt\.com|chat\.openai\.com)(?![\w.-])(?:[/?#][^\s<>"'`)]*)?/gi, "[redacted ChatGPT URL]");
14
+ }
15
+ function redactBlockerForMcp(blocker, privateValues = []) {
16
+ if (!blocker)
17
+ return undefined;
18
+ const context = [blocker.thread, ...privateValues];
19
+ return {
20
+ code: blocker.code,
21
+ message: redactContextText(blocker.message, context),
22
+ retryable: blocker.retryable,
23
+ ...(blocker.next_step !== undefined ? { next_step: redactContextText(blocker.next_step, context) } : {})
24
+ };
25
+ }
26
+ function redactResultForMcp(result) {
27
+ return {
28
+ ...result,
29
+ summary: result.status === "blocked" ? redactContextText(result.summary, [result.blocker?.thread]) : result.summary,
30
+ blocker: redactBlockerForMcp(result.blocker),
31
+ warnings: result.warnings.map((warning) => redactContextText(warning, [result.blocker?.thread]))
32
+ };
33
+ }
7
34
  /**
8
35
  * Drop the ChatGPT project name and thread URL before a session crosses the
9
36
  * MCP boundary. Both are personal context (the same way receipts redact the
@@ -17,6 +44,8 @@ function redactSessionForMcp(session) {
17
44
  redacted.project = undefined;
18
45
  if (Object.hasOwn(redacted, "thread"))
19
46
  redacted.thread = undefined;
47
+ redacted.blocker = redactBlockerForMcp(session.blocker, [session.thread, session.project]);
48
+ redacted.warnings = session.warnings.map((warning) => redactContextText(warning, [session.thread, session.project]));
20
49
  return redacted;
21
50
  }
22
51
  /**
@@ -27,17 +56,20 @@ function redactSessionForMcp(session) {
27
56
  * raw task file keeps them for local CLI inspection.
28
57
  */
29
58
  function redactTaskForMcp(task) {
30
- if (!task || typeof task !== "object" || !("provenance" in task) || !task.provenance)
31
- return task;
32
59
  const provenance = task.provenance;
33
- if (!Object.hasOwn(provenance, "thread") && !Object.hasOwn(provenance, "project"))
34
- return task;
35
60
  const redactedProvenance = { ...provenance };
36
61
  if (Object.hasOwn(redactedProvenance, "thread"))
37
62
  redactedProvenance.thread = undefined;
38
63
  if (Object.hasOwn(redactedProvenance, "project"))
39
64
  redactedProvenance.project = undefined;
40
- return { ...task, provenance: redactedProvenance };
65
+ if (provenance?.warnings) {
66
+ redactedProvenance.warnings = provenance.warnings.map((warning) => redactContextText(warning, [provenance.thread, provenance.project]));
67
+ }
68
+ return {
69
+ ...task,
70
+ ...(provenance ? { provenance: redactedProvenance } : {}),
71
+ blocker: redactBlockerForMcp(task.blocker, [provenance?.thread, provenance?.project])
72
+ };
41
73
  }
42
74
  export function createMcpToolHandlers(context) {
43
75
  const store = new BridgeStore(context.cwd, {
@@ -71,7 +103,7 @@ export function createMcpToolHandlers(context) {
71
103
  async bridge_claim_task(input) {
72
104
  assertMcpTextField(input.task_id, "task_id", MAX_MCP_SHORT_TEXT_BYTES);
73
105
  assertMcpTextField(input.claimed_by, "claimed_by", MAX_MCP_SHORT_TEXT_BYTES);
74
- return { task: await store.claimTask(input.task_id, input.claimed_by ?? claimedBy) };
106
+ return { task: redactTaskForMcp(await store.claimTask(input.task_id, input.claimed_by ?? claimedBy)) };
75
107
  },
76
108
  async bridge_complete_task(input) {
77
109
  assertMcpTextField(input.task_id, "task_id", MAX_MCP_SHORT_TEXT_BYTES);
@@ -87,7 +119,7 @@ export function createMcpToolHandlers(context) {
87
119
  warnings: input.warnings,
88
120
  provenance: { adapter: "mcp" }
89
121
  });
90
- return { result };
122
+ return { result: redactResultForMcp(result) };
91
123
  },
92
124
  async bridge_block_task(input) {
93
125
  assertMcpTextField(input.task_id, "task_id", MAX_MCP_SHORT_TEXT_BYTES);
@@ -111,14 +143,14 @@ export function createMcpToolHandlers(context) {
111
143
  },
112
144
  provenance: { adapter: "mcp" }
113
145
  });
114
- return { result };
146
+ return { result: redactResultForMcp(result) };
115
147
  },
116
148
  async bridge_list_results() {
117
- return { results: await store.listFinalizedResultsReadOnly() };
149
+ return { results: (await store.listFinalizedResultsReadOnly()).map(redactResultForMcp) };
118
150
  },
119
151
  async bridge_fetch_result(input) {
120
152
  assertMcpTextField(input.task_id, "task_id", MAX_MCP_SHORT_TEXT_BYTES);
121
- return { result: await store.getFinalizedResultReadOnly(input.task_id) };
153
+ return { result: redactResultForMcp(await store.getFinalizedResultReadOnly(input.task_id)) };
122
154
  },
123
155
  async bridge_fetch_result_artifact(input) {
124
156
  assertMcpTextField(input.task_id, "task_id", MAX_MCP_SHORT_TEXT_BYTES);
package/dist/mcp.js CHANGED
@@ -183,7 +183,7 @@ export function createServer(cwd = process.cwd(), options = {}) {
183
183
  .array(McpShortTextSchema)
184
184
  .max(4)
185
185
  .optional()
186
- .describe("ChatGPT composer tools to enable for this consult: \"deep-research\" (a browsed report; prodex presses start and waits out the run, which takes about ten minutes, so the timeout rises to 30 minutes automatically and the FULL report comes back as the answer - if the budget still runs out, the blocker carries the thread and pro_recover collects the report later), \"web-search\" (current facts, with the sources kept as links), \"create-image\". Deep research sometimes replies with a CLARIFYING QUESTION instead; answer it with a normal follow-up consult in the same thread."),
186
+ .describe("Rendered ChatGPT composer tools: \"web-search\" or \"create-image\". Only rendered answer content is returned. Automatic deep-research report retrieval is unsupported; requesting \"deep-research\" stops before sending. Use Deep research manually in the ChatGPT UI."),
187
187
  attach: z
188
188
  .array(McpShortTextSchema)
189
189
  .max(10)
@@ -233,7 +233,7 @@ export function createServer(cwd = process.cwd(), options = {}) {
233
233
  const browserRecover = options.browserRecover;
234
234
  if (browserRecover) {
235
235
  server.registerTool("pro_recover", {
236
- description: "Fetch a ChatGPT answer that finished AFTER a consult stopped waiting, and record it as a normal consult receipt. Use this whenever pro_consult came back with a timeout or a still-running blocker: those carry the thread URL, and the answer is almost always sitting in that thread. This is also how a deep research report is collected - a research run takes about ten minutes and keeps going even when the consult that started it has already returned. It sends no prompt, but it does navigate the visible tab to that thread, so it waits for any consult still streaming rather than walking the browser off it.",
236
+ description: "Read a stable, finished assistant answer rendered in the requested ChatGPT thread after a consult stopped waiting, and record a receipt. It sends no prompt and acquires the shared send lock before navigating. Wrong-thread, generating, missing, or changing answers are refused. Hidden transcripts and deep-research report retrieval are unsupported; inspect those manually in ChatGPT.",
237
237
  inputSchema: {
238
238
  thread: McpShortTextSchema.min(1).describe("The ChatGPT conversation URL from the blocker (its `thread` field)."),
239
239
  timeout_ms: z.number().int().positive().max(600_000).optional()
package/dist/registry.js CHANGED
@@ -1,6 +1,8 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import * as fs from "node:fs/promises";
2
3
  import * as os from "node:os";
3
4
  import * as path from "node:path";
5
+ import { readVerifiedUtf8File, writeVerifiedUtf8File } from "./safe-file.js";
4
6
  const SCHEMA_VERSION = 1;
5
7
  /**
6
8
  * Central registry of every bridge root on this machine, so local indexers
@@ -86,6 +88,7 @@ async function registerBridgeRootInner(root) {
86
88
  try {
87
89
  const file = bridgesRegistryPath();
88
90
  const abs = await canonicalize(root);
91
+ const parentRealPath = await preparePrivateRegistryParent(file);
89
92
  // Read-modify-write with a bounded verify-retry: rename gives torn-write
90
93
  // atomicity but not lost-update safety - two processes registering
91
94
  // DIFFERENT roots at the same instant would each read the old list and
@@ -95,7 +98,7 @@ async function registerBridgeRootInner(root) {
95
98
  for (let attempt = 0; attempt < 5; attempt++) {
96
99
  let roots = [];
97
100
  try {
98
- const parsed = JSON.parse(await fs.readFile(file, "utf8"));
101
+ const parsed = JSON.parse(await readRegistryFileForWrite(file, parentRealPath));
99
102
  if (Array.isArray(parsed?.roots)) {
100
103
  roots = parsed.roots.filter((r) => typeof r === "string");
101
104
  }
@@ -115,13 +118,19 @@ async function registerBridgeRootInner(root) {
115
118
  }
116
119
  survivors.push(abs);
117
120
  roots = survivors.length > MAX_REGISTRY_ROOTS ? survivors.slice(survivors.length - MAX_REGISTRY_ROOTS) : survivors;
118
- await fs.mkdir(path.dirname(file), { recursive: true });
119
- const tmp = `${file}.${process.pid}.${attempt}.tmp`;
120
- await fs.writeFile(tmp, `${JSON.stringify({ schema_version: SCHEMA_VERSION, roots }, null, 2)}\n`, "utf8");
121
- await fs.rename(tmp, file);
121
+ const tmp = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${randomUUID()}.tmp`);
122
+ try {
123
+ await writeVerifiedUtf8File(tmp, `${JSON.stringify({ schema_version: SCHEMA_VERSION, roots }, null, 2)}\n`, () => assertRegistryWritePathsSafe(file, parentRealPath), { create: true, exclusive: true, mode: 0o600 });
124
+ await assertRegistryWritePathsSafe(file, parentRealPath);
125
+ await fs.rename(tmp, file);
126
+ }
127
+ catch (error) {
128
+ await cleanupRegistryTemp(tmp, parentRealPath);
129
+ throw error;
130
+ }
122
131
  // Verify our root survived a concurrent writer's rename.
123
132
  try {
124
- const check = JSON.parse(await fs.readFile(file, "utf8"));
133
+ const check = JSON.parse(await readRegistryFileForWrite(file, parentRealPath));
125
134
  if (Array.isArray(check?.roots) && check.roots.includes(abs))
126
135
  return;
127
136
  }
@@ -134,3 +143,42 @@ async function registerBridgeRootInner(root) {
134
143
  // Advisory registry - never let it fail a bridge operation.
135
144
  }
136
145
  }
146
+ async function preparePrivateRegistryParent(file) {
147
+ const parent = path.dirname(file);
148
+ await fs.mkdir(parent, { recursive: true, mode: 0o700 });
149
+ const parentStat = await fs.lstat(parent);
150
+ if (parentStat.isSymbolicLink() || !parentStat.isDirectory()) {
151
+ throw new Error("Registry parent must be a real directory and must not be a symlink");
152
+ }
153
+ await fs.chmod(parent, 0o700);
154
+ return fs.realpath(parent);
155
+ }
156
+ async function assertRegistryWritePathsSafe(file, parentRealPath) {
157
+ const parent = path.dirname(file);
158
+ const parentStat = await fs.lstat(parent);
159
+ if (parentStat.isSymbolicLink() || !parentStat.isDirectory() || (await fs.realpath(parent)) !== parentRealPath) {
160
+ throw new Error("Registry parent changed during write");
161
+ }
162
+ try {
163
+ const targetStat = await fs.lstat(file);
164
+ if (targetStat.isSymbolicLink() || !targetStat.isFile()) {
165
+ throw new Error("Registry target must be a regular file and must not be a symlink");
166
+ }
167
+ }
168
+ catch (error) {
169
+ if (error.code !== "ENOENT")
170
+ throw error;
171
+ }
172
+ }
173
+ function readRegistryFileForWrite(file, parentRealPath) {
174
+ return readVerifiedUtf8File(file, () => assertRegistryWritePathsSafe(file, parentRealPath), { maxBytes: 1_000_000 });
175
+ }
176
+ async function cleanupRegistryTemp(tmp, parentRealPath) {
177
+ try {
178
+ if ((await fs.realpath(path.dirname(tmp))) === parentRealPath)
179
+ await fs.rm(tmp, { force: true });
180
+ }
181
+ catch {
182
+ // Do not follow a parent that changed while the advisory write was in flight.
183
+ }
184
+ }
@@ -1,9 +1,10 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { execFile, spawn } from "node:child_process";
3
+ import { realpath } from "node:fs/promises";
3
4
  import path from "node:path";
4
5
  import { promisify } from "node:util";
5
6
  import { assertResolvedRepoPathAllowed, resolveRepoPath } from "./repo.js";
6
- import { readVerifiedUtf8File, replaceVerifiedUtf8File } from "./safe-file.js";
7
+ import { readVerifiedUtf8File, replaceVerifiedUtf8File, withCrossProcessFileLock } from "./safe-file.js";
7
8
  const execFileAsync = promisify(execFile);
8
9
  const MAX_WRITE_BYTES = 1_000_000;
9
10
  let testHooks = {};
@@ -73,8 +74,28 @@ export async function applyRepoWriteDryRun(root, store, input) {
73
74
  if (metadata.preimage_sha256 !== input.preimage_sha256) {
74
75
  throw new Error(`Expected preimage does not match dry-run receipt`);
75
76
  }
77
+ const newContent = await readDryRunReplacementContent(store, metadata);
78
+ const resolvedPath = resolveRepoPath(root, metadata.path);
79
+ await assertResolvedRepoPathAllowed(root, resolvedPath, metadata.path);
80
+ const canonicalTarget = await realpath(resolvedPath);
81
+ const canonicalRoot = await realpath(root);
82
+ const lockFile = path.join(canonicalRoot, ".bridge", `.repo-write-${sha256(canonicalTarget)}.lock`);
83
+ // This ownership interval closes races among prodex applies. An editor that
84
+ // does not participate in the lock protocol can still replace the path.
85
+ return withCrossProcessFileLock(lockFile, {
86
+ waitMs: 30_000,
87
+ retryMs: 25,
88
+ privateParent: true,
89
+ busyError: (holder) => new Error(`Another prodex repo write for ${metadata.path} is in progress (pid ${holder.pid ?? "unknown"})`),
90
+ unavailableError: () => new Error(`The prodex repo write lock at ${lockFile} could not be recovered. Stop all writers before removing that lock and its matching .reap claim, then retry.`)
91
+ }, () => applyRepoWriteUnderLock(root, store, input, metadata, newContent, canonicalTarget));
92
+ }
93
+ async function applyRepoWriteUnderLock(root, store, input, metadata, newContent, canonicalTarget) {
76
94
  await assertGitHead(root, input.expected_head);
77
95
  const current = await readWritableExistingFile(root, metadata.path);
96
+ if ((await realpath(current.resolved)) !== canonicalTarget) {
97
+ throw new Error(`File target changed for ${metadata.path}`);
98
+ }
78
99
  const currentPreimage = sha256(current.content);
79
100
  if (currentPreimage !== input.preimage_sha256) {
80
101
  // A retry after a successful apply sees the file already holding the new
@@ -85,7 +106,6 @@ export async function applyRepoWriteDryRun(root, store, input) {
85
106
  }
86
107
  throw new Error(`File preimage changed for ${metadata.path}`);
87
108
  }
88
- const newContent = await readDryRunReplacementContent(store, metadata);
89
109
  let receipt;
90
110
  try {
91
111
  await replaceVerifiedUtf8File(current.resolved, newContent, async () => {
package/dist/safe-file.js CHANGED
@@ -1,11 +1,62 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { constants } from "node:fs";
3
- import { lstat, open, realpath, rename, rm } from "node:fs/promises";
3
+ import { chmod, link, lstat, mkdir, open, realpath, rename, rm } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  let testHooks = {};
6
6
  export function setSafeFileTestHooks(hooks) {
7
7
  testHooks = hooks;
8
8
  }
9
+ /**
10
+ * Serialize cooperating processes through a complete, atomically published
11
+ * lock record. A live process is never evicted based on elapsed time: callers
12
+ * must cancel their own work before releasing the lease.
13
+ */
14
+ export async function withCrossProcessFileLock(filePath, options, fn) {
15
+ const parentSnapshot = await prepareFileLockParent(filePath, options.privateParent === true);
16
+ const deadline = Date.now() + Math.max(0, options.waitMs);
17
+ const retryMs = Math.max(1, options.retryMs ?? 50);
18
+ let waited = false;
19
+ let retriedMissingAtDeadline = false;
20
+ let lease;
21
+ for (;;) {
22
+ lease = await tryAcquireFileLock(filePath, parentSnapshot);
23
+ if (lease)
24
+ break;
25
+ const snapshot = await readFileLockSnapshot(filePath, parentSnapshot);
26
+ if (!snapshot) {
27
+ // The holder commonly disappears between our failed link and inspection.
28
+ // Retry that acquisition once even at the deadline, but do not let a
29
+ // rapidly flapping path bypass a bounded caller's wait budget forever.
30
+ if (Date.now() >= deadline) {
31
+ if (retriedMissingAtDeadline)
32
+ throw options.unavailableError();
33
+ retriedMissingAtDeadline = true;
34
+ }
35
+ else {
36
+ await delay(Math.min(10, Math.max(1, deadline - Date.now())));
37
+ }
38
+ continue;
39
+ }
40
+ retriedMissingAtDeadline = false;
41
+ const holderAlive = snapshot.holder.pid !== undefined && processIsAlive(snapshot.holder.pid);
42
+ if (!holderAlive && (await reapFileLockSnapshot(filePath, snapshot, parentSnapshot)))
43
+ continue;
44
+ if (Date.now() >= deadline) {
45
+ throw holderAlive ? options.busyError(snapshot.holder) : options.unavailableError();
46
+ }
47
+ if (!waited) {
48
+ waited = true;
49
+ options.onWait?.(snapshot.holder);
50
+ }
51
+ await delay(Math.min(retryMs, Math.max(1, deadline - Date.now())));
52
+ }
53
+ try {
54
+ return await fn();
55
+ }
56
+ finally {
57
+ await releaseFileLockLease(filePath, lease, parentSnapshot);
58
+ }
59
+ }
9
60
  export async function readVerifiedUtf8File(filePath, validate, options = {}) {
10
61
  await validate();
11
62
  const parentSnapshot = await captureParentSnapshot(filePath);
@@ -286,9 +337,206 @@ async function openNoFollow(filePath, flags, operation, mode) {
286
337
  throw error;
287
338
  }
288
339
  }
340
+ async function prepareFileLockParent(filePath, makePrivate) {
341
+ const parentPath = path.dirname(filePath);
342
+ await mkdir(parentPath, { recursive: true, mode: 0o700 });
343
+ const parentStat = await lstat(parentPath);
344
+ if (parentStat.isSymbolicLink() || !parentStat.isDirectory()) {
345
+ throw new Error("Lock parent must be a real directory and must not be a symlink");
346
+ }
347
+ if (makePrivate)
348
+ await chmod(parentPath, 0o700);
349
+ const snapshot = await captureParentSnapshot(filePath);
350
+ if (!snapshot)
351
+ throw new Error("Lock files cannot use a descriptor-backed parent path");
352
+ await assertParentSnapshotStable(snapshot);
353
+ return snapshot;
354
+ }
355
+ async function tryAcquireFileLock(filePath, parentSnapshot) {
356
+ const owner = {
357
+ pid: process.pid,
358
+ nonce: randomUUID(),
359
+ started_at: new Date().toISOString()
360
+ };
361
+ const tokenPath = fileLockTokenPath(filePath, owner);
362
+ const handle = await openStableNoFollow(tokenPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, "write", parentSnapshot, 0o600);
363
+ try {
364
+ await handle.writeFile(`${JSON.stringify(owner)}\n`, "utf8");
365
+ await handle.chmod(0o600);
366
+ }
367
+ finally {
368
+ await handle.close();
369
+ }
370
+ try {
371
+ await assertParentSnapshotStable(parentSnapshot);
372
+ await link(tokenPath, filePath);
373
+ return { owner, tokenPath };
374
+ }
375
+ catch (error) {
376
+ await rm(tokenPath, { force: true }).catch(() => undefined);
377
+ if (isErrorCode(error, "EEXIST"))
378
+ return undefined;
379
+ throw error;
380
+ }
381
+ }
382
+ async function readFileLockSnapshot(filePath, parentSnapshot) {
383
+ let pathIdentity;
384
+ try {
385
+ const pathStat = await lstat(filePath, { bigint: true });
386
+ pathIdentity = { dev: pathStat.dev, ino: pathStat.ino };
387
+ if (pathStat.isSymbolicLink() || !pathStat.isFile()) {
388
+ return { identity: pathIdentity, holder: {}, unsafe: true };
389
+ }
390
+ }
391
+ catch (error) {
392
+ if (isMissingFileError(error))
393
+ return undefined;
394
+ throw error;
395
+ }
396
+ let handle;
397
+ try {
398
+ handle = await openStableNoFollow(filePath, constants.O_RDONLY, "read", parentSnapshot);
399
+ }
400
+ catch (error) {
401
+ if (isMissingFileError(error))
402
+ return undefined;
403
+ return { identity: pathIdentity, holder: {}, unsafe: true };
404
+ }
405
+ try {
406
+ const stat = await handle.stat({ bigint: true });
407
+ if (!stat.isFile())
408
+ return { identity: pathIdentity, holder: {}, unsafe: true };
409
+ const identity = { dev: stat.dev, ino: stat.ino };
410
+ if (stat.size > 16384n)
411
+ return { identity, holder: {} };
412
+ const content = await handle.readFile({ encoding: "utf8" });
413
+ const holder = parseFileLockHolder(content);
414
+ return { identity, holder, owner: parseFileLockOwner(holder) };
415
+ }
416
+ catch {
417
+ return { identity: pathIdentity, holder: {} };
418
+ }
419
+ finally {
420
+ await handle.close();
421
+ }
422
+ }
423
+ function parseFileLockHolder(content) {
424
+ try {
425
+ const parsed = JSON.parse(content);
426
+ return {
427
+ pid: Number.isSafeInteger(parsed.pid) && Number(parsed.pid) > 0 ? Number(parsed.pid) : undefined,
428
+ nonce: typeof parsed.nonce === "string" && /^[A-Za-z0-9-]{1,128}$/.test(parsed.nonce) ? parsed.nonce : undefined,
429
+ started_at: typeof parsed.started_at === "string" ? parsed.started_at : undefined
430
+ };
431
+ }
432
+ catch {
433
+ return {};
434
+ }
435
+ }
436
+ function parseFileLockOwner(holder) {
437
+ if (holder.pid === undefined || holder.nonce === undefined)
438
+ return undefined;
439
+ return { pid: holder.pid, nonce: holder.nonce, started_at: holder.started_at };
440
+ }
441
+ async function releaseFileLockLease(filePath, lease, parentSnapshot) {
442
+ try {
443
+ const [current, tokenIdentity] = await Promise.all([
444
+ readFileLockSnapshot(filePath, parentSnapshot),
445
+ readRegularFileIdentity(lease.tokenPath)
446
+ ]);
447
+ if (current?.owner?.pid === lease.owner.pid &&
448
+ current.owner.nonce === lease.owner.nonce &&
449
+ tokenIdentity &&
450
+ sameFileIdentity(current.identity, tokenIdentity)) {
451
+ await rm(filePath, { force: true });
452
+ }
453
+ }
454
+ catch {
455
+ // A release is best-effort; the unique token cleanup below cannot affect a successor.
456
+ }
457
+ finally {
458
+ await rm(lease.tokenPath, { force: true }).catch(() => undefined);
459
+ }
460
+ }
461
+ async function reapFileLockSnapshot(filePath, snapshot, parentSnapshot) {
462
+ if (snapshot.unsafe)
463
+ return false;
464
+ return reapClaimedFileLock(filePath, snapshot, parentSnapshot);
465
+ }
466
+ async function reapClaimedFileLock(filePath, snapshot, parentSnapshot) {
467
+ const claimPath = path.join(path.dirname(filePath), `.${path.basename(filePath)}.reap`);
468
+ try {
469
+ await link(filePath, claimPath);
470
+ }
471
+ catch (error) {
472
+ if (isMissingFileError(error))
473
+ return true;
474
+ // An interrupted reaper requires manual cleanup after all users stop.
475
+ // Reaping this claim recursively would reintroduce successor-unlink races.
476
+ if (isErrorCode(error, "EEXIST"))
477
+ return false;
478
+ throw error;
479
+ }
480
+ try {
481
+ await testHooks.afterLockReapClaim?.(filePath);
482
+ const [claimIdentity, current, ownerTokenIdentity] = await Promise.all([
483
+ readRegularFileIdentity(claimPath),
484
+ readFileLockSnapshot(filePath, parentSnapshot),
485
+ snapshot.owner ? readRegularFileIdentity(fileLockTokenPath(filePath, snapshot.owner)) : undefined
486
+ ]);
487
+ if (!claimIdentity ||
488
+ !current ||
489
+ !sameFileIdentity(claimIdentity, snapshot.identity) ||
490
+ !sameFileIdentity(current.identity, snapshot.identity)) {
491
+ return false;
492
+ }
493
+ await rm(filePath, { force: true });
494
+ if (snapshot.owner && ownerTokenIdentity && sameFileIdentity(ownerTokenIdentity, snapshot.identity)) {
495
+ await rm(fileLockTokenPath(filePath, snapshot.owner), { force: true }).catch(() => undefined);
496
+ }
497
+ return true;
498
+ }
499
+ finally {
500
+ await rm(claimPath, { force: true }).catch(() => undefined);
501
+ }
502
+ }
503
+ async function readRegularFileIdentity(filePath) {
504
+ try {
505
+ const stat = await lstat(filePath, { bigint: true });
506
+ if (stat.isSymbolicLink() || !stat.isFile())
507
+ return undefined;
508
+ return { dev: stat.dev, ino: stat.ino };
509
+ }
510
+ catch (error) {
511
+ if (isMissingFileError(error))
512
+ return undefined;
513
+ throw error;
514
+ }
515
+ }
516
+ function fileLockTokenPath(filePath, owner) {
517
+ return path.join(path.dirname(filePath), `.${path.basename(filePath)}.${owner.pid}.${owner.nonce}.owner`);
518
+ }
519
+ function sameFileIdentity(left, right) {
520
+ return left.dev === right.dev && left.ino === right.ino;
521
+ }
522
+ function processIsAlive(pid) {
523
+ try {
524
+ process.kill(pid, 0);
525
+ return true;
526
+ }
527
+ catch (error) {
528
+ return error.code === "EPERM";
529
+ }
530
+ }
531
+ function delay(ms) {
532
+ return new Promise((resolve) => setTimeout(resolve, ms));
533
+ }
289
534
  function procFdPath(fd) {
290
535
  return `/proc/self/fd/${fd}`;
291
536
  }
292
537
  function isMissingFileError(error) {
293
538
  return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
294
539
  }
540
+ function isErrorCode(error, code) {
541
+ return typeof error === "object" && error !== null && "code" in error && error.code === code;
542
+ }
package/dist/store.js CHANGED
@@ -389,7 +389,13 @@ export class BridgeStore {
389
389
  if (artifact.sha256 && sha256(content) !== artifact.sha256) {
390
390
  throw new Error(`Result artifact changed after finalization for ${taskId}: ${artifact.path} sha256 mismatch`);
391
391
  }
392
- return { artifact, content };
392
+ return {
393
+ artifact,
394
+ content,
395
+ ...(!artifact.sha256 ? {
396
+ warnings: ["legacy_artifact_unverified: this artifact has no recorded sha256; its current contents cannot be verified against finalization."]
397
+ } : {})
398
+ };
393
399
  }
394
400
  async withResultArtifactHashes(artifacts) {
395
401
  const withHashes = [];
package/dist/tui-flow.js CHANGED
@@ -10,7 +10,6 @@
10
10
  */
11
11
  export const SEND_KINDS = [
12
12
  { id: "chat", label: "Normal chat", hint: "ordinary Pro answer", tools: [] },
13
- { id: "deep-research", label: "Deep research", hint: "browsed report, runs about 10 minutes", tools: ["deep-research"] },
14
13
  { id: "web-search", label: "Web search", hint: "current facts, with sources", tools: ["web-search"] },
15
14
  { id: "create-image", label: "Create image", tools: ["create-image"] }
16
15
  ];