dextunnel 0.1.0

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.
Files changed (76) hide show
  1. package/LICENSE +211 -0
  2. package/README.md +112 -0
  3. package/SECURITY.md +27 -0
  4. package/SUPPORT.md +43 -0
  5. package/package.json +44 -0
  6. package/public/client-shared.js +1831 -0
  7. package/public/favicon.svg +11 -0
  8. package/public/host.html +29 -0
  9. package/public/host.js +2079 -0
  10. package/public/index.html +28 -0
  11. package/public/index.js +98 -0
  12. package/public/live-bridge-lifecycle.js +258 -0
  13. package/public/live-bridge-retry-state.js +61 -0
  14. package/public/live-selection-intent.js +79 -0
  15. package/public/remote-operator-state.js +316 -0
  16. package/public/remote.html +167 -0
  17. package/public/remote.js +3967 -0
  18. package/public/styles.css +2793 -0
  19. package/public/surface-view-state.js +89 -0
  20. package/public/voice-dictation.js +45 -0
  21. package/src/bin/desktop-rehydration-smoke.mjs +111 -0
  22. package/src/bin/dextunnel.mjs +41 -0
  23. package/src/bin/doctor.mjs +48 -0
  24. package/src/bin/launch-attest.mjs +39 -0
  25. package/src/bin/launch-status.mjs +49 -0
  26. package/src/bin/mobile-link-proxy.mjs +221 -0
  27. package/src/bin/mobile-proof.mjs +164 -0
  28. package/src/bin/mobile-transport-smoke.mjs +200 -0
  29. package/src/bin/probe-codex-app-server-write.mjs +36 -0
  30. package/src/bin/probe-codex-app-server.mjs +30 -0
  31. package/src/lib/agent-room-context.mjs +54 -0
  32. package/src/lib/agent-room-runtime.mjs +355 -0
  33. package/src/lib/agent-room-service.mjs +335 -0
  34. package/src/lib/agent-room-state.mjs +406 -0
  35. package/src/lib/agent-room-store.mjs +71 -0
  36. package/src/lib/agent-room-text.mjs +48 -0
  37. package/src/lib/app-server-contract.mjs +66 -0
  38. package/src/lib/app-server-runtime.mjs +60 -0
  39. package/src/lib/attachment-service.mjs +119 -0
  40. package/src/lib/bridge-api-handler.mjs +719 -0
  41. package/src/lib/bridge-runtime-lifecycle.mjs +51 -0
  42. package/src/lib/bridge-status-builder.mjs +60 -0
  43. package/src/lib/codex-app-server-client.mjs +1511 -0
  44. package/src/lib/companion-state.mjs +453 -0
  45. package/src/lib/control-lease-service.mjs +180 -0
  46. package/src/lib/debug-harness-service.mjs +173 -0
  47. package/src/lib/desktop-integration.mjs +146 -0
  48. package/src/lib/desktop-rehydration-smoke.mjs +269 -0
  49. package/src/lib/dextunnel-cli.mjs +122 -0
  50. package/src/lib/discovery-docs.mjs +1321 -0
  51. package/src/lib/fake-codex-app-server-bridge.mjs +340 -0
  52. package/src/lib/install-preflight.mjs +373 -0
  53. package/src/lib/interaction-resolution-service.mjs +185 -0
  54. package/src/lib/interaction-state.mjs +360 -0
  55. package/src/lib/launch-release-bar.mjs +158 -0
  56. package/src/lib/live-control-state.mjs +107 -0
  57. package/src/lib/live-payload-builder.mjs +298 -0
  58. package/src/lib/live-selection-transition-state.mjs +49 -0
  59. package/src/lib/live-transcript-state.mjs +549 -0
  60. package/src/lib/mobile-network-profile.mjs +39 -0
  61. package/src/lib/mock-codex-adapter.mjs +62 -0
  62. package/src/lib/operator-diagnostics.mjs +82 -0
  63. package/src/lib/repo-changes-service.mjs +527 -0
  64. package/src/lib/runtime-config.mjs +106 -0
  65. package/src/lib/selection-state-service.mjs +214 -0
  66. package/src/lib/session-store.mjs +355 -0
  67. package/src/lib/shared-room-state.mjs +473 -0
  68. package/src/lib/shared-selection-state.mjs +40 -0
  69. package/src/lib/sse-hub.mjs +35 -0
  70. package/src/lib/static-surface-service.mjs +71 -0
  71. package/src/lib/surface-access.mjs +189 -0
  72. package/src/lib/surface-presence-service.mjs +118 -0
  73. package/src/lib/surface-request-guard.mjs +52 -0
  74. package/src/lib/thread-sync-state.mjs +536 -0
  75. package/src/lib/watcher-lifecycle.mjs +287 -0
  76. package/src/server.mjs +1446 -0
@@ -0,0 +1,82 @@
1
+ function uniqueByCode(items = []) {
2
+ const seen = new Set();
3
+ const result = [];
4
+ for (const item of items) {
5
+ const code = String(item?.code || "").trim();
6
+ if (!code || seen.has(code)) {
7
+ continue;
8
+ }
9
+ seen.add(code);
10
+ result.push(item);
11
+ }
12
+ return result;
13
+ }
14
+
15
+ function hostSurfaceAttached(selectedAttachments = []) {
16
+ return (selectedAttachments || []).some((entry) => entry?.surface === "host" && Number(entry?.count || 0) > 0);
17
+ }
18
+
19
+ export function buildOperatorDiagnostics({
20
+ bridgeStatus = {},
21
+ controlLeaseForSelection = null,
22
+ selectedAttachments = [],
23
+ selectedThreadId = null,
24
+ watcherConnected = false
25
+ } = {}) {
26
+ const diagnostics = [];
27
+
28
+ if (!watcherConnected) {
29
+ diagnostics.push({
30
+ code: "bridge_unavailable",
31
+ domain: "network",
32
+ severity: "warn",
33
+ summary: "Session bridge unavailable."
34
+ });
35
+ }
36
+
37
+ if (!selectedThreadId) {
38
+ diagnostics.push({
39
+ code: "no_selected_room",
40
+ domain: "room",
41
+ severity: "warn",
42
+ summary: "No shared room selected."
43
+ });
44
+ }
45
+
46
+ if (!hostSurfaceAttached(selectedAttachments)) {
47
+ diagnostics.push({
48
+ code: "host_unavailable",
49
+ domain: "host",
50
+ severity: "info",
51
+ summary: "Host surface not attached."
52
+ });
53
+ }
54
+
55
+ if (controlLeaseForSelection) {
56
+ diagnostics.push({
57
+ code: "control_held",
58
+ domain: "lease",
59
+ severity: "info",
60
+ summary: "Control is currently held elsewhere."
61
+ });
62
+ }
63
+
64
+ diagnostics.push({
65
+ code: "desktop_restart_required",
66
+ domain: "desktop",
67
+ severity: "info",
68
+ summary: "Desktop Codex still requires restart to rehydrate external turns."
69
+ });
70
+
71
+ if (bridgeStatus.lastError) {
72
+ diagnostics.push({
73
+ code: "bridge_last_error",
74
+ detail: String(bridgeStatus.lastError),
75
+ domain: "network",
76
+ severity: "warn",
77
+ summary: "Last bridge error recorded."
78
+ });
79
+ }
80
+
81
+ return uniqueByCode(diagnostics);
82
+ }
@@ -0,0 +1,527 @@
1
+ import { execFile } from "node:child_process";
2
+ import { open, readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { promisify } from "node:util";
5
+
6
+ const execFileAsync = promisify(execFile);
7
+
8
+ function classifyChange(statusCode) {
9
+ if (statusCode === "??") {
10
+ return "untracked";
11
+ }
12
+
13
+ if (statusCode.includes("R")) {
14
+ return "renamed";
15
+ }
16
+
17
+ if (statusCode.includes("A")) {
18
+ return "added";
19
+ }
20
+
21
+ if (statusCode.includes("D")) {
22
+ return "deleted";
23
+ }
24
+
25
+ if (statusCode.includes("U")) {
26
+ return "unmerged";
27
+ }
28
+
29
+ return "modified";
30
+ }
31
+
32
+ function parseStatusLine(line) {
33
+ const statusCode = line.slice(0, 2);
34
+ const rawPath = line.slice(3);
35
+ const [fromPath, toPath] = rawPath.includes(" -> ") ? rawPath.split(" -> ") : [null, rawPath];
36
+
37
+ return {
38
+ fromPath,
39
+ kind: classifyChange(statusCode),
40
+ path: toPath,
41
+ statusCode
42
+ };
43
+ }
44
+
45
+ function shouldIgnoreChangePath(relativePath) {
46
+ if (relativePath == null) {
47
+ return false;
48
+ }
49
+
50
+ const normalized = String(relativePath || "").replace(/^"+|"+$/g, "");
51
+ if (!normalized) {
52
+ return false;
53
+ }
54
+
55
+ const ignoredPrefixes = [
56
+ ".agent/",
57
+ ".git/",
58
+ ".npm-cache/",
59
+ ".playwright-browsers/",
60
+ ".playwright-home/",
61
+ ".playwright/",
62
+ ".next/",
63
+ ".runtime/",
64
+ "build/",
65
+ "coverage/",
66
+ "dist/",
67
+ "node_modules/"
68
+ ];
69
+
70
+ if (ignoredPrefixes.some((prefix) => normalized.startsWith(prefix))) {
71
+ return true;
72
+ }
73
+
74
+ return normalized === ".DS_Store" || normalized.endsWith("/.DS_Store");
75
+ }
76
+
77
+ function countDiffLines(text) {
78
+ let additions = 0;
79
+ let deletions = 0;
80
+
81
+ for (const line of String(text || "").split("\n")) {
82
+ if (/^\+\+\+|^@@/.test(line)) {
83
+ continue;
84
+ }
85
+ if (/^---/.test(line)) {
86
+ continue;
87
+ }
88
+ if (line.startsWith("+")) {
89
+ additions += 1;
90
+ } else if (line.startsWith("-")) {
91
+ deletions += 1;
92
+ }
93
+ }
94
+
95
+ return {
96
+ additions,
97
+ deletions
98
+ };
99
+ }
100
+
101
+ function trimDiffPreview(text, maxChars = 12000) {
102
+ if (text.length <= maxChars) {
103
+ return {
104
+ diffPreview: text,
105
+ truncated: false
106
+ };
107
+ }
108
+
109
+ return {
110
+ diffPreview: `${text.slice(0, maxChars).trimEnd()}\n\n... diff truncated ...`,
111
+ truncated: true
112
+ };
113
+ }
114
+
115
+ async function runGit(cwd, args, gitCommandTimeoutMs) {
116
+ const { stdout } = await execFileAsync("git", args, { cwd, maxBuffer: 1024 * 1024 * 8, timeout: gitCommandTimeoutMs });
117
+ return stdout;
118
+ }
119
+
120
+ async function buildUntrackedDiff(repoRoot, relativePath) {
121
+ const filePath = path.join(repoRoot, relativePath);
122
+ const content = await readFile(filePath, "utf8");
123
+ const lines = content.split("\n");
124
+ const preview = [`diff --git a/${relativePath} b/${relativePath}`, "new file mode 100644", "--- /dev/null", `+++ b/${relativePath}`]
125
+ .concat(lines.slice(0, 160).map((line) => `+${line}`))
126
+ .join("\n");
127
+
128
+ return {
129
+ additions: lines.length,
130
+ deletions: 0,
131
+ ...trimDiffPreview(preview)
132
+ };
133
+ }
134
+
135
+ async function buildTrackedDiff(repoRoot, relativePath, gitCommandTimeoutMs) {
136
+ const diff = await runGit(repoRoot, ["diff", "--no-ext-diff", "--no-color", "--unified=3", "HEAD", "--", relativePath], gitCommandTimeoutMs);
137
+ const preview = trimDiffPreview(diff);
138
+ const { additions, deletions } = countDiffLines(diff);
139
+
140
+ return {
141
+ additions,
142
+ deletions,
143
+ ...preview
144
+ };
145
+ }
146
+
147
+ async function readUtf8Tail(filePath, maxBytes) {
148
+ const handle = await open(filePath, "r");
149
+
150
+ try {
151
+ const { size } = await handle.stat();
152
+ const length = Math.min(size, maxBytes);
153
+ if (length === 0) {
154
+ return "";
155
+ }
156
+
157
+ const buffer = Buffer.alloc(length);
158
+ await handle.read(buffer, 0, length, size - length);
159
+
160
+ let text = buffer.toString("utf8");
161
+ if (size > length) {
162
+ const firstNewline = text.indexOf("\n");
163
+ text = firstNewline === -1 ? "" : text.slice(firstNewline + 1);
164
+ }
165
+
166
+ return text;
167
+ } finally {
168
+ await handle.close();
169
+ }
170
+ }
171
+
172
+ function escapeRegExp(value) {
173
+ return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
174
+ }
175
+
176
+ function normalizeRepoRelativePath(filePath, repoRoot) {
177
+ const cleaned = String(filePath || "").replace(/[),"'`\]]+$/g, "");
178
+ if (!cleaned) {
179
+ return null;
180
+ }
181
+
182
+ const relative = path.relative(repoRoot, cleaned);
183
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
184
+ return null;
185
+ }
186
+
187
+ return relative.replace(/^\.\/+/, "");
188
+ }
189
+
190
+ async function extractSessionPathHints(threadPath, repoRoot, candidatePaths = [], sessionLogTailBytes) {
191
+ if (!threadPath || !repoRoot) {
192
+ return new Map();
193
+ }
194
+
195
+ try {
196
+ const tail = await readUtf8Tail(threadPath, sessionLogTailBytes);
197
+ if (!tail) {
198
+ return new Map();
199
+ }
200
+
201
+ const absolutePathPattern = new RegExp(`${escapeRegExp(repoRoot)}/[^\\s"'` + "`" + `)\\]]+`, "g");
202
+ const scores = new Map();
203
+
204
+ for (const match of tail.matchAll(absolutePathPattern)) {
205
+ const relativePath = normalizeRepoRelativePath(match[0], repoRoot);
206
+ if (!relativePath || shouldIgnoreChangePath(relativePath)) {
207
+ continue;
208
+ }
209
+
210
+ scores.set(relativePath, (scores.get(relativePath) || 0) + 1);
211
+ }
212
+
213
+ for (const candidate of candidatePaths) {
214
+ const relativePath = String(candidate || "").replace(/^\.\/+/, "");
215
+ if (!relativePath || shouldIgnoreChangePath(relativePath)) {
216
+ continue;
217
+ }
218
+
219
+ const relativePattern = new RegExp(
220
+ `(^|[^A-Za-z0-9_./-])${escapeRegExp(relativePath)}(?=$|[^A-Za-z0-9_./-])`,
221
+ "g"
222
+ );
223
+ const matches = [...tail.matchAll(relativePattern)].length;
224
+ if (matches > 0) {
225
+ scores.set(relativePath, (scores.get(relativePath) || 0) + matches * 2);
226
+ }
227
+ }
228
+
229
+ return scores;
230
+ } catch {
231
+ return new Map();
232
+ }
233
+ }
234
+
235
+ function prioritizeParsedChanges(parsed, hintScores = new Map()) {
236
+ const ranked = parsed
237
+ .map((entry, index) => {
238
+ const normalizedPath = String(entry.path || "");
239
+ const pathScore = hintScores.get(entry.path) || 0;
240
+ const fromScore = entry.fromPath ? hintScores.get(entry.fromPath) || 0 : 0;
241
+ const codeSurfaceBoost = /^(src|public|test)\//.test(normalizedPath)
242
+ ? 8
243
+ : /\.(mjs|js|ts|tsx|css|html)$/i.test(normalizedPath)
244
+ ? 5
245
+ : 0;
246
+ const rootAppBoost = /^(package\.json|README\.md|AGENTS\.md)$/i.test(normalizedPath) ? 2 : 0;
247
+ const docsPenalty = /^docs\//.test(normalizedPath) ? -2 : 0;
248
+ const trackedBoost = entry.kind === "untracked" ? 0 : 2;
249
+ const sessionHits = pathScore + fromScore;
250
+
251
+ return {
252
+ ...entry,
253
+ _index: index,
254
+ _score:
255
+ pathScore * 10 +
256
+ fromScore * 6 +
257
+ codeSurfaceBoost +
258
+ rootAppBoost +
259
+ docsPenalty +
260
+ trackedBoost,
261
+ _sessionHits: sessionHits,
262
+ relevance:
263
+ sessionHits > 0
264
+ ? "mentioned in session"
265
+ : codeSurfaceBoost > 0 || rootAppBoost > 0
266
+ ? "repo hot path"
267
+ : ""
268
+ };
269
+ })
270
+ .sort((a, b) => b._score - a._score || a._index - b._index);
271
+
272
+ return ranked.map(({ _index, _score, _sessionHits, ...entry }) => ({
273
+ ...entry,
274
+ sessionHits: _sessionHits
275
+ }));
276
+ }
277
+
278
+ function parseUnifiedDiff(diffText) {
279
+ const normalized = String(diffText || "").replace(/\r\n/g, "\n");
280
+ if (!normalized.trim()) {
281
+ return [];
282
+ }
283
+
284
+ const items = [];
285
+ let current = null;
286
+
287
+ function finalizeCurrent() {
288
+ if (!current) {
289
+ return;
290
+ }
291
+
292
+ const previewText = current.lines.join("\n").trim();
293
+ const { additions, deletions } = countDiffLines(previewText);
294
+ items.push({
295
+ additions,
296
+ deletions,
297
+ diffPreview: trimDiffPreview(previewText).diffPreview,
298
+ fromPath: current.fromPath,
299
+ kind: current.kind,
300
+ path: current.path,
301
+ statusCode: current.statusCode
302
+ });
303
+ current = null;
304
+ }
305
+
306
+ for (const line of normalized.split("\n")) {
307
+ if (line.startsWith("diff --git ")) {
308
+ finalizeCurrent();
309
+ const match = line.match(/^diff --git a\/(.+) b\/(.+)$/);
310
+ current = {
311
+ fromPath: match?.[1] || null,
312
+ kind: "modified",
313
+ lines: [line],
314
+ path: match?.[2] || "current turn",
315
+ statusCode: "M"
316
+ };
317
+ continue;
318
+ }
319
+
320
+ if (!current) {
321
+ current = {
322
+ fromPath: null,
323
+ kind: "modified",
324
+ lines: [],
325
+ path: "current turn",
326
+ statusCode: "M"
327
+ };
328
+ }
329
+
330
+ if (line.startsWith("new file mode ")) {
331
+ current.kind = "added";
332
+ current.statusCode = "A";
333
+ } else if (line.startsWith("deleted file mode ")) {
334
+ current.kind = "deleted";
335
+ current.statusCode = "D";
336
+ } else if (line.startsWith("rename from ")) {
337
+ current.kind = "renamed";
338
+ current.fromPath = line.slice("rename from ".length).trim();
339
+ current.statusCode = "R";
340
+ } else if (line.startsWith("rename to ")) {
341
+ current.path = line.slice("rename to ".length).trim();
342
+ }
343
+
344
+ current.lines.push(line);
345
+ }
346
+
347
+ finalizeCurrent();
348
+
349
+ return items.filter((entry) => !shouldIgnoreChangePath(entry.path) && !shouldIgnoreChangePath(entry.fromPath));
350
+ }
351
+
352
+ export function createRepoChangesService({
353
+ cacheTtlMs = 2500,
354
+ gitCommandTimeoutMs = 4000,
355
+ sessionLogTailBytes = 256 * 1024
356
+ } = {}) {
357
+ const repoChangesCache = new Map();
358
+
359
+ async function inspectRepoChanges(cwd, { threadPath = null } = {}) {
360
+ try {
361
+ const repoRoot = (await runGit(cwd, ["rev-parse", "--show-toplevel"], gitCommandTimeoutMs)).trim();
362
+ const statusOutput = await runGit(repoRoot, ["status", "--porcelain=v1", "--untracked-files=all"], gitCommandTimeoutMs);
363
+ const parsed = statusOutput
364
+ .split("\n")
365
+ .map((line) => line.trimEnd())
366
+ .filter(Boolean)
367
+ .map(parseStatusLine)
368
+ .filter((entry) => !shouldIgnoreChangePath(entry.path) && !shouldIgnoreChangePath(entry.fromPath));
369
+ const candidatePaths = [...new Set(parsed.flatMap((entry) => [entry.path, entry.fromPath]).filter(Boolean))];
370
+ const hintScores = await extractSessionPathHints(threadPath, repoRoot, candidatePaths, sessionLogTailBytes);
371
+ const rankedParsed = hintScores.size ? prioritizeParsedChanges(parsed, hintScores) : parsed;
372
+ const relevantParsed = rankedParsed.filter((entry) => entry.sessionHits > 0);
373
+ const overflowParsed = rankedParsed.filter((entry) => entry.sessionHits <= 0);
374
+ const displayParsed = hintScores.size
375
+ ? [...relevantParsed.slice(0, 8), ...overflowParsed.slice(0, 2)].slice(0, 10)
376
+ : rankedParsed.slice(0, 12);
377
+
378
+ const items = await Promise.all(
379
+ displayParsed.map(async (entry) => {
380
+ const diff =
381
+ entry.kind === "untracked"
382
+ ? await buildUntrackedDiff(repoRoot, entry.path)
383
+ : await buildTrackedDiff(repoRoot, entry.path, gitCommandTimeoutMs);
384
+
385
+ return {
386
+ ...entry,
387
+ ...diff
388
+ };
389
+ })
390
+ );
391
+ const focusPaths = rankedParsed
392
+ .filter((entry) => entry.relevance)
393
+ .slice(0, 4)
394
+ .map((entry) => entry.path);
395
+ const hiddenCount = Math.max(rankedParsed.length - items.length, 0);
396
+
397
+ return {
398
+ cwd,
399
+ focusPaths,
400
+ hiddenCount,
401
+ items,
402
+ repoRoot,
403
+ source: hintScores.size ? "git_session" : "git",
404
+ shownCount: items.length,
405
+ supported: true,
406
+ totalCount: rankedParsed.length
407
+ };
408
+ } catch {
409
+ return {
410
+ cwd,
411
+ hiddenCount: 0,
412
+ items: [],
413
+ repoRoot: null,
414
+ source: "git",
415
+ shownCount: 0,
416
+ supported: false,
417
+ totalCount: 0
418
+ };
419
+ }
420
+ }
421
+
422
+ function repoChangesCacheKey(cwd, { threadPath = null } = {}) {
423
+ return JSON.stringify({
424
+ cwd: String(cwd || ""),
425
+ threadPath: String(threadPath || "")
426
+ });
427
+ }
428
+
429
+ function pruneRepoChangesCache() {
430
+ const now = Date.now();
431
+ for (const [key, value] of repoChangesCache.entries()) {
432
+ if (value.promise) {
433
+ continue;
434
+ }
435
+
436
+ if ((value.expiresAt || 0) <= now) {
437
+ repoChangesCache.delete(key);
438
+ }
439
+ }
440
+ }
441
+
442
+ return {
443
+ buildLiveTurnChanges({ cwd, diff, threadId, turnId }) {
444
+ const items = parseUnifiedDiff(diff).map((entry) => ({
445
+ ...entry,
446
+ relevance: "current live turn",
447
+ sessionHits: 0
448
+ }));
449
+
450
+ return {
451
+ cwd,
452
+ focusPaths: items.slice(0, 4).map((entry) => entry.path),
453
+ hiddenCount: 0,
454
+ items,
455
+ repoRoot: cwd || null,
456
+ source: "live_turn",
457
+ shownCount: items.length,
458
+ supported: true,
459
+ threadId,
460
+ totalCount: items.length,
461
+ turnId
462
+ };
463
+ },
464
+
465
+ async getCachedRepoChanges(cwd, { threadPath = null } = {}) {
466
+ pruneRepoChangesCache();
467
+
468
+ const normalizedCwd = String(cwd || "");
469
+ const normalizedThreadPath = String(threadPath || "");
470
+ const key = repoChangesCacheKey(normalizedCwd, { threadPath: normalizedThreadPath });
471
+ const now = Date.now();
472
+ const cached = repoChangesCache.get(key);
473
+
474
+ if (cached?.value && (cached.expiresAt || 0) > now) {
475
+ return cached.value;
476
+ }
477
+
478
+ if (cached?.promise) {
479
+ return cached.promise;
480
+ }
481
+
482
+ const promise = inspectRepoChanges(normalizedCwd, { threadPath: normalizedThreadPath })
483
+ .then((payload) => {
484
+ repoChangesCache.set(key, {
485
+ cwd: normalizedCwd,
486
+ expiresAt: Date.now() + cacheTtlMs,
487
+ threadPath: normalizedThreadPath,
488
+ value: payload
489
+ });
490
+ return payload;
491
+ })
492
+ .catch((error) => {
493
+ repoChangesCache.delete(key);
494
+ throw error;
495
+ });
496
+
497
+ repoChangesCache.set(key, {
498
+ cwd: normalizedCwd,
499
+ expiresAt: now + cacheTtlMs,
500
+ promise,
501
+ threadPath: normalizedThreadPath,
502
+ value: cached?.value || null
503
+ });
504
+
505
+ return promise;
506
+ },
507
+
508
+ invalidateRepoChangesCache({ cwd = "", threadPath = "" } = {}) {
509
+ if (!cwd && !threadPath) {
510
+ repoChangesCache.clear();
511
+ return;
512
+ }
513
+
514
+ for (const [key, value] of repoChangesCache.entries()) {
515
+ if (cwd && value.cwd !== cwd) {
516
+ continue;
517
+ }
518
+
519
+ if (threadPath && value.threadPath !== threadPath) {
520
+ continue;
521
+ }
522
+
523
+ repoChangesCache.delete(key);
524
+ }
525
+ }
526
+ };
527
+ }
@@ -0,0 +1,106 @@
1
+ import { existsSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ function enabledFlag(value) {
7
+ return /^(1|true|yes|on)$/i.test(String(value || "").trim());
8
+ }
9
+
10
+ function resolveRuntimeProfile(env = process.env) {
11
+ return String(env.DEXTUNNEL_PROFILE || "default").trim().toLowerCase() === "dev" ? "dev" : "default";
12
+ }
13
+
14
+ function resolveCodexBinaryPath(env = process.env) {
15
+ return (
16
+ env.DEXTUNNEL_CODEX_BINARY ||
17
+ env.CODEX_BINARY ||
18
+ (existsSync("/Applications/Codex.app/Contents/Resources/codex")
19
+ ? "/Applications/Codex.app/Contents/Resources/codex"
20
+ : "codex")
21
+ );
22
+ }
23
+
24
+ function parseFakeAgentRoomFailures(value) {
25
+ const source = String(value || "").trim();
26
+ if (!source) {
27
+ return {};
28
+ }
29
+
30
+ const result = {};
31
+ for (const rawEntry of source.split(",")) {
32
+ const entry = String(rawEntry || "").trim();
33
+ if (!entry) {
34
+ continue;
35
+ }
36
+
37
+ const [participantRaw, modeRaw] = entry.split(":");
38
+ const participantId = String(participantRaw || "").trim().toLowerCase();
39
+ const rawSpec = String(modeRaw || "").trim().toLowerCase();
40
+ if (!participantId || !rawSpec) {
41
+ continue;
42
+ }
43
+
44
+ const match = rawSpec.match(/^(timeout|malformed|error)(?:\*(\d+))?$/);
45
+ if (!match) {
46
+ continue;
47
+ }
48
+
49
+ const mode = match[1];
50
+ const count = Math.max(1, Number(match[2] || 1));
51
+ if (!["timeout", "malformed", "error"].includes(mode)) {
52
+ continue;
53
+ }
54
+
55
+ result[participantId] = { count, mode };
56
+ }
57
+
58
+ return result;
59
+ }
60
+
61
+ export function createRuntimeConfig({
62
+ cwd = process.cwd(),
63
+ env = process.env,
64
+ importMetaUrl = import.meta.url
65
+ } = {}) {
66
+ const runtimeProfile = resolveRuntimeProfile(env);
67
+ const devToolsEnabled =
68
+ runtimeProfile === "dev" || enabledFlag(env.DEXTUNNEL_ENABLE_DEVTOOLS);
69
+ const useFakeAppServer = enabledFlag(env.DEXTUNNEL_FAKE_APP_SERVER);
70
+ const fakeSendDelayMs = Number(env.DEXTUNNEL_FAKE_SEND_DELAY_MS || 0);
71
+ const codexBinaryPath = resolveCodexBinaryPath(env);
72
+ const appServerListenUrl = env.DEXTUNNEL_APP_SERVER_URL || "ws://127.0.0.1:4321";
73
+ const exposeHostSurface = enabledFlag(env.DEXTUNNEL_EXPOSE_HOST_SURFACE);
74
+ const host = String(env.DEXTUNNEL_HOST || "127.0.0.1").trim() || "127.0.0.1";
75
+ const port = Number(env.PORT || 4317);
76
+ const __dirname = path.dirname(fileURLToPath(importMetaUrl));
77
+ const publicDir = path.join(__dirname, "..", "public");
78
+ const attachmentDir = path.join(tmpdir(), "dextunnel-attachments");
79
+ const agentRoomDir = path.join(cwd, ".agent", "artifacts", "runtime", "agent-room");
80
+ const useFakeAgentRoom = enabledFlag(env.DEXTUNNEL_FAKE_AGENT_ROOM);
81
+ const fakeAgentRoomFailures = parseFakeAgentRoomFailures(env.DEXTUNNEL_FAKE_AGENT_ROOM_FAILURES);
82
+
83
+ return {
84
+ agentRoomDir,
85
+ appServerListenUrl,
86
+ attachmentDir,
87
+ codexBinaryPath,
88
+ cwd,
89
+ devToolsEnabled,
90
+ exposeHostSurface,
91
+ fakeAgentRoomFailures,
92
+ fakeSendDelayMs,
93
+ host,
94
+ mimeTypes: {
95
+ ".css": "text/css; charset=utf-8",
96
+ ".html": "text/html; charset=utf-8",
97
+ ".js": "text/javascript; charset=utf-8",
98
+ ".json": "application/json; charset=utf-8"
99
+ },
100
+ port,
101
+ publicDir,
102
+ runtimeProfile,
103
+ useFakeAgentRoom,
104
+ useFakeAppServer
105
+ };
106
+ }