@opengeni/runtime 0.6.0 → 0.7.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 (44) hide show
  1. package/dist/{chunk-BHA2SU3H.js → chunk-RYDCVTMW.js} +1959 -208
  2. package/dist/chunk-RYDCVTMW.js.map +1 -0
  3. package/dist/{index-otZkoLYq.d.ts → index-DIiiwQY2.d.ts} +693 -55
  4. package/dist/index.d.ts +293 -121
  5. package/dist/index.js +1819 -786
  6. package/dist/index.js.map +1 -1
  7. package/dist/sandbox/index.d.ts +1 -1
  8. package/dist/sandbox/index.js +79 -3
  9. package/package.json +21 -20
  10. package/src/codex-tool-search.ts +185 -31
  11. package/src/context-compaction.ts +78 -243
  12. package/src/history-sanitizer.ts +172 -20
  13. package/src/index.ts +2573 -897
  14. package/src/metrics.ts +28 -2
  15. package/src/sandbox/capabilities.ts +12 -3
  16. package/src/sandbox/channel-a.ts +420 -72
  17. package/src/sandbox/display-stack.ts +18 -4
  18. package/src/sandbox/errors.ts +13 -0
  19. package/src/sandbox/index.ts +398 -112
  20. package/src/sandbox/op-correlation.ts +70 -0
  21. package/src/sandbox/providers/index.ts +9 -3
  22. package/src/sandbox/providers/modal.ts +99 -24
  23. package/src/sandbox/providers/selfhosted.ts +1 -4
  24. package/src/sandbox/recording.ts +146 -90
  25. package/src/sandbox/routing/backend-resolver.ts +144 -10
  26. package/src/sandbox/routing/routing-session.ts +129 -25
  27. package/src/sandbox/select.ts +60 -8
  28. package/src/sandbox/selfhosted/capabilities.ts +59 -25
  29. package/src/sandbox/selfhosted/control-rpc.ts +160 -28
  30. package/src/sandbox/selfhosted/fault-rendering.ts +226 -0
  31. package/src/sandbox/selfhosted/op-observer.ts +54 -0
  32. package/src/sandbox/selfhosted/op-stream.ts +801 -0
  33. package/src/sandbox/selfhosted/op-testing.ts +468 -0
  34. package/src/sandbox/selfhosted/op-transport.ts +151 -0
  35. package/src/sandbox/selfhosted/retry-policy.ts +177 -0
  36. package/src/sandbox/selfhosted/session.ts +498 -55
  37. package/src/sandbox/selfhosted/testing.ts +60 -19
  38. package/src/sandbox/stream-port.ts +17 -5
  39. package/src/sandbox/stream-token.ts +8 -2
  40. package/src/sandbox/terminal-server.ts +18 -4
  41. package/src/sandbox-computer.ts +332 -70
  42. package/src/usage-telemetry.ts +73 -0
  43. package/dist/chunk-BHA2SU3H.js.map +0 -1
  44. package/src/image-history.ts +0 -149
@@ -39,7 +39,6 @@ import type {
39
39
  GitChangedPayload,
40
40
  GitCommit,
41
41
  GitDiffHunk,
42
- GitDiffLine,
43
42
  GitDiffRequest,
44
43
  GitDiffResponse,
45
44
  GitFileDiff,
@@ -90,27 +89,50 @@ export type ChannelAEditor = {
90
89
  export type ChannelASession = {
91
90
  exec?(args: ChannelAExecArgs): Promise<ChannelAExecResult>;
92
91
  execCommand?(args: ChannelAExecArgs): Promise<string>;
93
- readFile?(args: { path: string; runAs?: string; maxBytes?: number }): Promise<string | Uint8Array>;
94
- writeStdin?(args: { sessionId: number; chars?: string; yieldTimeMs?: number; maxOutputTokens?: number }): Promise<string>;
92
+ readFile?(args: {
93
+ path: string;
94
+ runAs?: string;
95
+ maxBytes?: number;
96
+ }): Promise<string | Uint8Array>;
97
+ writeStdin?(args: {
98
+ sessionId: number;
99
+ chars?: string;
100
+ yieldTimeMs?: number;
101
+ maxOutputTokens?: number;
102
+ }): Promise<string>;
95
103
  createEditor?(runAs?: string): ChannelAEditor;
96
104
  supportsPty?(): boolean;
97
105
  };
98
106
 
99
107
  // ── Errors mapped to HTTP status at the route. ───────────────────────────────
100
108
  export class ChannelAValidationError extends Error {
101
- constructor(message: string) { super(message); this.name = "ChannelAValidationError"; }
109
+ constructor(message: string) {
110
+ super(message);
111
+ this.name = "ChannelAValidationError";
112
+ }
102
113
  }
103
114
  export class ChannelAConflictError extends Error {
104
- constructor(message: string) { super(message); this.name = "ChannelAConflictError"; }
115
+ constructor(message: string) {
116
+ super(message);
117
+ this.name = "ChannelAConflictError";
118
+ }
105
119
  }
106
120
  export class ChannelANotFoundError extends Error {
107
- constructor(message: string) { super(message); this.name = "ChannelANotFoundError"; }
121
+ constructor(message: string) {
122
+ super(message);
123
+ this.name = "ChannelANotFoundError";
124
+ }
108
125
  }
109
126
  export class ChannelAUnsupportedError extends Error {
110
- constructor(message: string) { super(message); this.name = "ChannelAUnsupportedError"; }
127
+ constructor(message: string) {
128
+ super(message);
129
+ this.name = "ChannelAUnsupportedError";
130
+ }
111
131
  }
112
132
 
113
- export type ChannelAEmitter = (events: { type: SessionEventType; payload: unknown }[]) => Promise<void>;
133
+ export type ChannelAEmitter = (
134
+ events: { type: SessionEventType; payload: unknown }[],
135
+ ) => Promise<void>;
114
136
 
115
137
  export type SandboxChannelAServiceOptions = {
116
138
  session: ChannelASession;
@@ -133,9 +155,24 @@ export type SandboxChannelAServiceOptions = {
133
155
  runAs?: string;
134
156
  };
135
157
 
158
+ export const REPOSITORY_DISCOVERY_LIMIT = 256;
159
+ export type RepositoryDiscoveryDegradedReason =
160
+ | "command_failed"
161
+ | "command_timed_out"
162
+ | "result_limit_exceeded";
163
+ export type RepositoryDiscoveryResult = {
164
+ repos: string[];
165
+ complete: boolean;
166
+ degradedReason: RepositoryDiscoveryDegradedReason | null;
167
+ };
168
+
169
+ const REPOSITORY_DISCOVERY_TRUNCATED_SENTINEL = "__OPENGENI_REPOSITORY_DISCOVERY_TRUNCATED__";
170
+ const REPOSITORY_DISCOVERY_STATUS_PREFIX = "__OPENGENI_REPOSITORY_DISCOVERY_STATUS__:";
171
+
136
172
  const NUL = String.fromCharCode(0); // \0 NUL — find/porcelain/numstat -z separator
137
173
  const US = String.fromCharCode(0x1f); // \x1f unit sep — git-log field separator
138
174
  const RS = String.fromCharCode(0x1e); // \x1e record sep — git-log record separator
175
+ const SELFHOSTED_VIRTUAL_ROOT = "/workspace";
139
176
 
140
177
  export class SandboxChannelAService {
141
178
  private readonly session: ChannelASession;
@@ -160,7 +197,11 @@ export class SandboxChannelAService {
160
197
  const hasExec = Boolean(s.exec || s.execCommand);
161
198
  const hasFs = Boolean(s.readFile && (s.exec || s.execCommand || s.createEditor));
162
199
  return {
163
- FileSystem: { available: hasFs, readOnly: !(s.exec || s.createEditor), root: this.workspaceRoot },
200
+ FileSystem: {
201
+ available: hasFs,
202
+ readOnly: !(s.exec || s.createEditor),
203
+ root: this.workspaceRoot,
204
+ },
164
205
  Terminal: {
165
206
  events: hasExec,
166
207
  exec: hasExec,
@@ -175,7 +216,13 @@ export class SandboxChannelAService {
175
216
  // (the local/docker sessions return raw output); falls back to execCommand +
176
217
  // a banner strip (last resort; banner-truncation can mangle, so exec is always
177
218
  // preferred). Throws ChannelAUnsupportedError when neither exists.
178
- private async run(args: ChannelAExecArgs): Promise<{ stdout: string; stderr: string; exitCode: number | null; sessionId?: number; wallTimeSeconds: number }> {
219
+ private async run(args: ChannelAExecArgs): Promise<{
220
+ stdout: string;
221
+ stderr: string;
222
+ exitCode: number | null;
223
+ sessionId?: number;
224
+ wallTimeSeconds: number;
225
+ }> {
179
226
  const withRunAs = this.runAs ? { ...args, runAs: this.runAs } : args;
180
227
  if (this.session.exec) {
181
228
  const r = await this.session.exec(withRunAs);
@@ -220,7 +267,10 @@ export class SandboxChannelAService {
220
267
  const depthArg = Math.max(1, req.depth);
221
268
  const hidden = req.includeHidden ? "" : ` -not -path '*/.*'`;
222
269
  const gnuFind = `find ${findRoot} -mindepth 1 -maxdepth ${depthArg}${hidden} -printf '%y\\t%s\\t%T@\\t%m\\t%p\\0' 2>/dev/null`;
223
- let { stdout } = await this.run({ cmd: `bash -lc ${shellQuote(gnuFind)}`, workdir: this.workspaceRoot || undefined });
270
+ let { stdout } = await this.run({
271
+ cmd: `bash -lc ${shellQuote(gnuFind)}`,
272
+ workdir: this.workspaceRoot || undefined,
273
+ });
224
274
  if (!stdout) {
225
275
  const portableFind = [
226
276
  `find ${findRoot} -mindepth 1 -maxdepth ${depthArg}${hidden} -print0 2>/dev/null | while IFS= read -r -d '' p; do`,
@@ -230,7 +280,10 @@ export class SandboxChannelAService {
230
280
  `printf '%s\\t%s\\t%s\\t%s\\t%s\\0' "$t" "$size" "$mtime" "$mode" "$p";`,
231
281
  `done`,
232
282
  ].join(" ");
233
- ({ stdout } = await this.run({ cmd: `bash -lc ${shellQuote(portableFind)}`, workdir: this.workspaceRoot || undefined }));
283
+ ({ stdout } = await this.run({
284
+ cmd: `bash -lc ${shellQuote(portableFind)}`,
285
+ workdir: this.workspaceRoot || undefined,
286
+ }));
234
287
  }
235
288
 
236
289
  const entries = stdout.split(NUL).filter((s) => s.length > 0);
@@ -250,7 +303,10 @@ export class SandboxChannelAService {
250
303
  let count = 0;
251
304
  let truncated = false;
252
305
  for (const entry of entries) {
253
- if (count >= req.maxEntries) { truncated = true; break; }
306
+ if (count >= req.maxEntries) {
307
+ truncated = true;
308
+ break;
309
+ }
254
310
  const parts = entry.split("\t");
255
311
  if (parts.length < 5) continue;
256
312
  const [typeChar, sizeStr, mtimeStr, modeStr, ...pathParts] = parts;
@@ -289,7 +345,11 @@ export class SandboxChannelAService {
289
345
  }
290
346
  let raw: string | Uint8Array;
291
347
  try {
292
- raw = await this.session.readFile({ path: this.joinRoot(path), maxBytes: req.maxBytes, ...(this.runAs ? { runAs: this.runAs } : {}) });
348
+ raw = await this.session.readFile({
349
+ path: this.joinRoot(path),
350
+ maxBytes: req.maxBytes,
351
+ ...(this.runAs ? { runAs: this.runAs } : {}),
352
+ });
293
353
  } catch (error) {
294
354
  // The provider's native readFile applies a REMOTE workspace-escape guard:
295
355
  // a SYMLINK whose target resolves outside /workspace (e.g.
@@ -303,7 +363,9 @@ export class SandboxChannelAService {
303
363
  if (isWorkspaceEscapeError(error)) {
304
364
  return await this.fsReadViaExec(path, req);
305
365
  }
306
- throw new ChannelANotFoundError(`file not found: ${path} (${error instanceof Error ? error.message : String(error)})`);
366
+ throw new ChannelANotFoundError(
367
+ `file not found: ${path} (${error instanceof Error ? error.message : String(error)})`,
368
+ );
307
369
  }
308
370
  const bytes = typeof raw === "string" ? Buffer.from(raw, "utf8") : Buffer.from(raw);
309
371
  return this.shapeRead(path, bytes, req);
@@ -315,7 +377,9 @@ export class SandboxChannelAService {
315
377
  * node itself is in-workspace). `base64 <path>` follows the symlink. */
316
378
  private async fsReadViaExec(path: string, req: FsReadRequest): Promise<FsReadResponse> {
317
379
  const abs = this.joinRoot(path);
318
- const { stdout, exitCode } = await this.run({ cmd: `base64 ${shellQuote(abs)} 2>/dev/null | head -c ${Math.ceil(req.maxBytes * 1.4)}` });
380
+ const { stdout, exitCode } = await this.run({
381
+ cmd: `base64 ${shellQuote(abs)} 2>/dev/null | head -c ${Math.ceil(req.maxBytes * 1.4)}`,
382
+ });
319
383
  if (exitCode !== null && exitCode !== 0 && stdout === "") {
320
384
  // The target may be a dangling symlink or a link to a directory; surface a
321
385
  // clean, typed not-found rather than a raw provider validation error.
@@ -344,7 +408,10 @@ export class SandboxChannelAService {
344
408
  async fsWrite(req: FsWriteRequest): Promise<FsWriteResponse> {
345
409
  const path = assertSafeRelPath(req.path);
346
410
  const abs = this.joinRoot(path);
347
- const bytes = req.encoding === "base64" ? Buffer.from(req.content, "base64") : Buffer.from(req.content, "utf8");
411
+ const bytes =
412
+ req.encoding === "base64"
413
+ ? Buffer.from(req.content, "base64")
414
+ : Buffer.from(req.content, "utf8");
348
415
 
349
416
  if (!req.overwrite) {
350
417
  const { exitCode } = await this.run({ cmd: `test -e ${shellQuote(abs)}` });
@@ -369,13 +436,21 @@ export class SandboxChannelAService {
369
436
  // text payload (binary cannot go through apply-patch).
370
437
  if (req.encoding !== "base64" && this.session.createEditor) {
371
438
  const ok = await this.tryEditorWrite(abs, req.content);
372
- if (!ok) throw new ChannelAValidationError(`failed to write ${path}: ${stderr || `exit ${exitCode}`}`);
439
+ if (!ok)
440
+ throw new ChannelAValidationError(
441
+ `failed to write ${path}: ${stderr || `exit ${exitCode}`}`,
442
+ );
373
443
  } else {
374
- throw new ChannelAValidationError(`failed to write ${path}: ${stderr || `exit ${exitCode}`}`);
444
+ throw new ChannelAValidationError(
445
+ `failed to write ${path}: ${stderr || `exit ${exitCode}`}`,
446
+ );
375
447
  }
376
448
  }
377
449
  this.revision++;
378
- await this.emitFsChanged([{ path, kind: "modified", isDir: false, sizeBytes: bytes.byteLength }], "write");
450
+ await this.emitFsChanged(
451
+ [{ path, kind: "modified", isDir: false, sizeBytes: bytes.byteLength }],
452
+ "write",
453
+ );
379
454
  return { path, sizeBytes: bytes.byteLength, revision: this.revision };
380
455
  }
381
456
 
@@ -384,7 +459,10 @@ export class SandboxChannelAService {
384
459
  if (!editor?.createFile) return false;
385
460
  try {
386
461
  // The apply-patch op shape — a whole-file "create" diff (last-writer-wins).
387
- const diff = content.split("\n").map((line) => `+${line}`).join("\n");
462
+ const diff = content
463
+ .split("\n")
464
+ .map((line) => `+${line}`)
465
+ .join("\n");
388
466
  await editor.createFile({ type: "create_file", path: absPath, diff });
389
467
  return true;
390
468
  } catch {
@@ -398,7 +476,9 @@ export class SandboxChannelAService {
398
476
  const flag = req.recursive ? "-rf" : "-f";
399
477
  const { exitCode, stderr } = await this.run({ cmd: `rm ${flag} ${shellQuote(abs)}` });
400
478
  if (exitCode !== null && exitCode !== 0) {
401
- throw new ChannelAValidationError(`failed to delete ${path}: ${stderr || `exit ${exitCode}`}`);
479
+ throw new ChannelAValidationError(
480
+ `failed to delete ${path}: ${stderr || `exit ${exitCode}`}`,
481
+ );
402
482
  }
403
483
  this.revision++;
404
484
  await this.emitFsChanged([{ path, kind: "deleted", isDir: false, sizeBytes: null }], "write");
@@ -428,7 +508,9 @@ export class SandboxChannelAService {
428
508
  cmd: `mv ${flag}${shellQuote(abs)} ${shellQuote(newAbs)}`,
429
509
  });
430
510
  if (exitCode !== null && exitCode !== 0) {
431
- throw new ChannelAValidationError(`failed to move ${path} -> ${newPath}: ${stderr || `exit ${exitCode}`}`);
511
+ throw new ChannelAValidationError(
512
+ `failed to move ${path} -> ${newPath}: ${stderr || `exit ${exitCode}`}`,
513
+ );
432
514
  }
433
515
  this.revision++;
434
516
  await this.emitFsChanged(
@@ -460,11 +542,26 @@ export class SandboxChannelAService {
460
542
 
461
543
  async gitStatus(req: GitStatusRequest): Promise<GitStatusResponse> {
462
544
  const repo = this.repoWorkdir(req.path);
463
- const inside = await this.run({ cmd: "git rev-parse --is-inside-work-tree 2>/dev/null", workdir: repo });
545
+ const inside = await this.run({
546
+ cmd: "git rev-parse --is-inside-work-tree 2>/dev/null",
547
+ workdir: repo,
548
+ });
464
549
  if (inside.stdout.trim() !== "true") {
465
- return { isRepo: false, head: null, detached: false, upstream: null, ahead: 0, behind: 0, files: [], revision: this.revision };
550
+ return {
551
+ isRepo: false,
552
+ head: null,
553
+ detached: false,
554
+ upstream: null,
555
+ ahead: 0,
556
+ behind: 0,
557
+ files: [],
558
+ revision: this.revision,
559
+ };
466
560
  }
467
- const { stdout } = await this.run({ cmd: "git status --porcelain=v2 --branch -z", workdir: repo });
561
+ const { stdout } = await this.run({
562
+ cmd: "git status --porcelain=v2 --branch -z",
563
+ workdir: repo,
564
+ });
468
565
  return { ...parsePorcelainV2(stdout), revision: this.revision };
469
566
  }
470
567
 
@@ -480,7 +577,10 @@ export class SandboxChannelAService {
480
577
 
481
578
  // Pass 1: numstat (stats + binary detection). -z gives NUL-separated fields;
482
579
  // a rename emits old\0new for that record's path fields.
483
- const numstat = await this.run({ cmd: `git -c core.quotePath=false diff --no-color -z --numstat ${range}${pathspec}`.trim(), workdir: repo });
580
+ const numstat = await this.run({
581
+ cmd: `git -c core.quotePath=false diff --no-color -z --numstat ${range}${pathspec}`.trim(),
582
+ workdir: repo,
583
+ });
484
584
  const stats = parseNumstatZ(numstat.stdout);
485
585
 
486
586
  const files: GitFileDiff[] = [];
@@ -507,7 +607,9 @@ export class SandboxChannelAService {
507
607
  workdir: repo,
508
608
  });
509
609
  const oversized = Buffer.byteLength(patch.stdout, "utf8") > req.maxBytesPerFile;
510
- const parsed = oversized ? { hunks: [] as GitDiffHunk[], status: "modified" as GitFileStatusCode } : parseUnifiedPatch(patch.stdout);
610
+ const parsed = oversized
611
+ ? { hunks: [] as GitDiffHunk[], status: "modified" as GitFileStatusCode }
612
+ : parseUnifiedPatch(patch.stdout);
511
613
  files.push({
512
614
  path: target,
513
615
  oldPath: stat.oldPath,
@@ -534,7 +636,10 @@ export class SandboxChannelAService {
534
636
  if (exitCode !== null && exitCode !== 0) {
535
637
  return { commits: [], hasMore: false };
536
638
  }
537
- const records = stdout.split(RS).map((r) => r.replace(/^\n/, "")).filter((r) => r.trim().length > 0);
639
+ const records = stdout
640
+ .split(RS)
641
+ .map((r) => r.replace(/^\n/, ""))
642
+ .filter((r) => r.trim().length > 0);
538
643
  const commits: GitCommit[] = [];
539
644
  for (const rec of records.slice(0, req.maxCount)) {
540
645
  const f = rec.split(US);
@@ -572,27 +677,149 @@ export class SandboxChannelAService {
572
677
  return {
573
678
  commit: null,
574
679
  files: [],
575
- blob: { content: encoding === "base64" ? clamped.toString("base64") : clamped.toString("utf8"), encoding, sizeBytes: clamped.byteLength, truncated },
680
+ blob: {
681
+ content: encoding === "base64" ? clamped.toString("base64") : clamped.toString("utf8"),
682
+ encoding,
683
+ sizeBytes: clamped.byteLength,
684
+ truncated,
685
+ },
576
686
  revision: this.revision,
577
687
  };
578
688
  }
579
689
  // Commit mode: metadata + diff vs first parent.
580
- const log = await this.gitLog({ path: req.path, ref: req.ref, maxCount: 1, skip: 0, pathspec: [] });
690
+ const log = await this.gitLog({
691
+ path: req.path,
692
+ ref: req.ref,
693
+ maxCount: 1,
694
+ skip: 0,
695
+ pathspec: [],
696
+ });
581
697
  const commit = log.commits[0] ?? null;
582
- const diff = await this.gitDiff({ path: req.path, staged: false, fromRef: `${req.ref}^`, toRef: req.ref, pathspec: [], contextLines: 3, maxBytesPerFile: req.maxBytesPerFile });
698
+ const diff = await this.gitDiff({
699
+ path: req.path,
700
+ staged: false,
701
+ fromRef: `${req.ref}^`,
702
+ toRef: req.ref,
703
+ pathspec: [],
704
+ contextLines: 3,
705
+ maxBytesPerFile: req.maxBytesPerFile,
706
+ });
583
707
  return { commit, files: diff.files, blob: null, revision: this.revision };
584
708
  }
585
709
 
586
- /** Detect repo roots within the workspace (for the Git.repos capability). */
587
- async detectRepos(): Promise<string[]> {
710
+ /**
711
+ * Detect repo roots within every supported workspace layout.
712
+ *
713
+ * The platform seeds GitHub repositories at
714
+ * `repos/<owner>/<repo>`, which puts the `.git` marker at depth four from the
715
+ * workspace root. Do not reintroduce a fixed maxdepth here. Traversal is
716
+ * bounded instead by pruning known machine/build residue, a wall-clock
717
+ * timeout, and a result limit. `.git` may be either a directory (ordinary
718
+ * clone/submodule) or a file (linked worktree).
719
+ *
720
+ * Callers that persist an authoritative snapshot must use this detailed
721
+ * result and surface an incomplete discovery. The compact `detectRepos()`
722
+ * wrapper remains for capability negotiation, where the best available list
723
+ * is preferable to failing the whole capabilities document.
724
+ */
725
+ async detectReposDetailed(): Promise<RepositoryDiscoveryResult> {
588
726
  try {
589
- const { stdout } = await this.run({ cmd: `find . -maxdepth 3 -name .git -type d 2>/dev/null`, workdir: this.workspaceRoot || undefined });
590
- return stdout.split("\n").map((l) => l.trim()).filter(Boolean).map((g) => dirnameAbs(stripDotSlash(g, "")) || "");
727
+ const discovery = [
728
+ "find . -xdev",
729
+ "\\( -name .git \\( -type d -o -type f \\) -print -prune \\)",
730
+ "-o \\( -type d \\( -name node_modules -o -name .cache -o -name .local",
731
+ "-o -name dist -o -name build -o -name target -o -name .venv",
732
+ "-o -name __pycache__ -o -name .next \\) -prune \\)",
733
+ "2>/dev/null",
734
+ ].join(" ");
735
+ // The status trailer is necessary for providers whose only structural
736
+ // surface is `execCommand`: that fallback has stdout but no numeric exit
737
+ // code. Without the trailer a timed-out discovery could look like a
738
+ // successful authoritative zero-repo result.
739
+ //
740
+ // Do not use GNU `timeout` here. Connected Machines include macOS, whose
741
+ // stock userland does not provide it. The watchdog kills `find` directly
742
+ // after the wall-clock budget and records whether that kill won the race.
743
+ // Its stdio is detached so cancelling a fast discovery cannot leave the
744
+ // watchdog's `sleep` holding the provider output pipe open.
745
+ const command = [
746
+ 'results_file=$(mktemp "${TMPDIR:-/tmp}/opengeni-repository-discovery.XXXXXX") || exit 70',
747
+ 'timeout_file="${results_file}.timed-out"',
748
+ "discovery_pid=",
749
+ "watchdog_pid=",
750
+ 'cleanup() { if [ -n "$discovery_pid" ]; then kill "$discovery_pid" 2>/dev/null || true; fi; if [ -n "$watchdog_pid" ]; then kill "$watchdog_pid" 2>/dev/null || true; fi; rm -f "$results_file" "$timeout_file"; }',
751
+ "abort() { trap - EXIT; cleanup; exit 143; }",
752
+ "trap cleanup EXIT",
753
+ "trap abort HUP INT TERM",
754
+ `${discovery} > "$results_file" &`,
755
+ "discovery_pid=$!",
756
+ '(sleeper_pid=; stop_watchdog() { if [ -n "$sleeper_pid" ]; then kill "$sleeper_pid" 2>/dev/null || true; wait "$sleeper_pid" 2>/dev/null || true; fi; exit 0; }; trap stop_watchdog HUP INT TERM; sleep 15 & sleeper_pid=$!; wait "$sleeper_pid"; sleeper_pid=; if kill -TERM "$discovery_pid" 2>/dev/null; then : > "$timeout_file"; fi) </dev/null >/dev/null 2>&1 &',
757
+ "watchdog_pid=$!",
758
+ 'wait "$discovery_pid"',
759
+ "status=$?",
760
+ "discovery_pid=",
761
+ 'kill "$watchdog_pid" 2>/dev/null || true',
762
+ 'wait "$watchdog_pid" 2>/dev/null || true',
763
+ "watchdog_pid=",
764
+ 'if [ "$status" -ne 0 ] && [ -f "$timeout_file" ]; then status=124; fi',
765
+ `if [ "$status" -eq 0 ]; then awk 'NR <= ${REPOSITORY_DISCOVERY_LIMIT} { print } NR == ${
766
+ REPOSITORY_DISCOVERY_LIMIT + 1
767
+ } { print "${REPOSITORY_DISCOVERY_TRUNCATED_SENTINEL}" }' "$results_file"; fi`,
768
+ `printf '\\n${REPOSITORY_DISCOVERY_STATUS_PREFIX}%s\\n' "$status"`,
769
+ 'exit "$status"',
770
+ ].join("\n");
771
+ const { stdout } = await this.run({
772
+ cmd: `bash -lc ${shellQuote(command)}`,
773
+ workdir: this.workspaceRoot || undefined,
774
+ yieldTimeMs: 20_000,
775
+ // At most 256 Unix paths (PATH_MAX each) plus the status trailer. This
776
+ // prevents a provider's ordinary output cap from dropping the trailer
777
+ // and making a partial list look complete.
778
+ maxOutputTokens: 300_000,
779
+ });
780
+ const statusLine = stdout
781
+ .split("\n")
782
+ .find((line) => line.startsWith(REPOSITORY_DISCOVERY_STATUS_PREFIX));
783
+ const embeddedStatus = statusLine
784
+ ? Number.parseInt(statusLine.slice(REPOSITORY_DISCOVERY_STATUS_PREFIX.length), 10)
785
+ : null;
786
+ // The command always prints this trailer after discovery settles. Missing
787
+ // means provider-side output truncation or an outer-shell failure, neither
788
+ // of which is authoritative discovery even if exec reports exit 0.
789
+ const status = Number.isInteger(embeddedStatus) ? embeddedStatus : null;
790
+ if (status === 124) {
791
+ return { repos: [], complete: false, degradedReason: "command_timed_out" };
792
+ }
793
+ if (status !== 0) {
794
+ return { repos: [], complete: false, degradedReason: "command_failed" };
795
+ }
796
+ const lines = stdout
797
+ .split("\n")
798
+ .map((l) => l.trim())
799
+ .filter((line) => Boolean(line) && !line.startsWith(REPOSITORY_DISCOVERY_STATUS_PREFIX));
800
+ const truncated = lines.includes(REPOSITORY_DISCOVERY_TRUNCATED_SENTINEL);
801
+ const repos = [
802
+ ...new Set(
803
+ lines
804
+ .filter((line) => line !== REPOSITORY_DISCOVERY_TRUNCATED_SENTINEL)
805
+ .map((gitMarker) => dirnameAbs(stripDotSlash(gitMarker, "")) || ""),
806
+ ),
807
+ ].sort();
808
+ return {
809
+ repos,
810
+ complete: !truncated,
811
+ degradedReason: truncated ? "result_limit_exceeded" : null,
812
+ };
591
813
  } catch {
592
- return [];
814
+ return { repos: [], complete: false, degradedReason: "command_failed" };
593
815
  }
594
816
  }
595
817
 
818
+ /** Detect repo roots within the workspace (for the Git.repos capability). */
819
+ async detectRepos(): Promise<string[]> {
820
+ return (await this.detectReposDetailed()).repos;
821
+ }
822
+
596
823
  // ════════════════════════ Terminal exec + PTY (A2) ════════════════════════
597
824
 
598
825
  /** Run a bounded command, return buffered stdout/stderr + exit code inline. The
@@ -602,15 +829,23 @@ export class SandboxChannelAService {
602
829
  async terminalExec(req: TerminalExecRequest): Promise<TerminalExecResponse> {
603
830
  const r = await this.run({
604
831
  cmd: req.command,
605
- workdir: this.repoWorkdir(req.cwd),
832
+ workdir: this.terminalWorkdir(req.cwd),
606
833
  yieldTimeMs: req.timeoutMs,
607
834
  });
608
835
  const running = r.exitCode === null && typeof r.sessionId === "number";
609
836
  if (req.emitStream && (r.stdout || r.stderr)) {
610
837
  const events: { type: SessionEventType; payload: unknown }[] = [];
611
838
  const commandId = crypto.randomUUID();
612
- if (r.stdout) events.push({ type: "sandbox.command.output.delta", payload: { stream: "stdout", chunk: r.stdout, commandId, seq: 0 } });
613
- if (r.stderr) events.push({ type: "sandbox.command.output.delta", payload: { stream: "stderr", chunk: r.stderr, commandId, seq: 1 } });
839
+ if (r.stdout)
840
+ events.push({
841
+ type: "sandbox.command.output.delta",
842
+ payload: { stream: "stdout", chunk: r.stdout, commandId, seq: 0 },
843
+ });
844
+ if (r.stderr)
845
+ events.push({
846
+ type: "sandbox.command.output.delta",
847
+ payload: { stream: "stderr", chunk: r.stderr, commandId, seq: 1 },
848
+ });
614
849
  await this.emitEvents(events);
615
850
  }
616
851
  return {
@@ -627,12 +862,20 @@ export class SandboxChannelAService {
627
862
  * writeStdin can drive it. Returns the supportsInput gate (false when the
628
863
  * backend has no writeStdin). The caller emits terminal.pty.started after it
629
864
  * persists the row. */
630
- async ptyOpen(req: PtyOpenRequest, ptyId: string): Promise<{ response: PtyOpenResponse; execSessionId: number | null; shell: string; initialOutput: string }> {
865
+ async ptyOpen(
866
+ req: PtyOpenRequest,
867
+ ptyId: string,
868
+ ): Promise<{
869
+ response: PtyOpenResponse;
870
+ execSessionId: number | null;
871
+ shell: string;
872
+ initialOutput: string;
873
+ }> {
631
874
  const supportsInput = Boolean(this.session.supportsPty?.() && this.session.writeStdin);
632
875
  const shell = req.shell ?? "/bin/bash";
633
876
  const r = await this.run({
634
877
  cmd: shell,
635
- workdir: this.repoWorkdir(req.cwd),
878
+ workdir: this.terminalWorkdir(req.cwd),
636
879
  tty: true,
637
880
  login: true,
638
881
  yieldTimeMs: 250,
@@ -652,7 +895,11 @@ export class SandboxChannelAService {
652
895
  if (!this.session.writeStdin) {
653
896
  throw new ChannelAUnsupportedError("interactive terminal unsupported on this backend");
654
897
  }
655
- const out = await this.session.writeStdin({ sessionId: execSessionId, chars: data, yieldTimeMs: 250 });
898
+ const out = await this.session.writeStdin({
899
+ sessionId: execSessionId,
900
+ chars: data,
901
+ yieldTimeMs: 250,
902
+ });
656
903
  // The Modal exec surface reports a vanished exec-session as a NON-throwing
657
904
  // string ("write_stdin failed: session not found: N") that we used to stream
658
905
  // verbatim into the terminal. That happens when the persisted exec-session no
@@ -672,7 +919,11 @@ export class SandboxChannelAService {
672
919
  async ptyResize(req: PtyResizeRequest, execSessionId: number): Promise<void> {
673
920
  if (!this.session.writeStdin) return;
674
921
  // Send a stty in-band on the same pty session.
675
- await this.session.writeStdin({ sessionId: execSessionId, chars: `stty cols ${req.cols} rows ${req.rows}\n`, yieldTimeMs: 50 });
922
+ await this.session.writeStdin({
923
+ sessionId: execSessionId,
924
+ chars: `stty cols ${req.cols} rows ${req.rows}\n`,
925
+ yieldTimeMs: 50,
926
+ });
676
927
  }
677
928
 
678
929
  /** Close an open PTY: write exit/EOF. The caller marks the row closed + emits
@@ -690,7 +941,9 @@ export class SandboxChannelAService {
690
941
  // ──────────────────────────── helpers ──────────────────────────────────────
691
942
 
692
943
  /** The current FS revision (for the caller to persist/seed). */
693
- currentRevision(): number { return this.revision; }
944
+ currentRevision(): number {
945
+ return this.revision;
946
+ }
694
947
 
695
948
  private joinRoot(rel: string): string {
696
949
  if (!this.workspaceRoot) return rel === "" ? "." : rel;
@@ -703,13 +956,35 @@ export class SandboxChannelAService {
703
956
  return joined === "." ? this.workspaceRoot || undefined : joined;
704
957
  }
705
958
 
959
+ private terminalWorkdir(cwd: string): string | undefined {
960
+ // Model-facing terminal tools may send the manifest-rooted virtual frame.
961
+ // Preserve it for sessions like selfhosted whose own adapter maps it onto
962
+ // the real machine working dir; repo-relative dock fs/git still use repoWorkdir.
963
+ if (cwd === SELFHOSTED_VIRTUAL_ROOT || cwd.startsWith(`${SELFHOSTED_VIRTUAL_ROOT}/`)) {
964
+ return cwd;
965
+ }
966
+ return this.repoWorkdir(cwd);
967
+ }
968
+
706
969
  private async emitEvents(events: { type: SessionEventType; payload: unknown }[]): Promise<void> {
707
970
  if (!this.emit || events.length === 0) return;
708
- try { await this.emit(events); } catch { /* durable spine retries; not fatal */ }
971
+ try {
972
+ await this.emit(events);
973
+ } catch {
974
+ /* durable spine retries; not fatal */
975
+ }
709
976
  }
710
977
 
711
- private async emitFsChanged(changes: FsChangedPayload["changes"], source: FsChangedPayload["source"]): Promise<void> {
712
- const payload: FsChangedPayload = { changes, source, revision: this.revision, leaseEpoch: this.leaseEpoch };
978
+ private async emitFsChanged(
979
+ changes: FsChangedPayload["changes"],
980
+ source: FsChangedPayload["source"],
981
+ ): Promise<void> {
982
+ const payload: FsChangedPayload = {
983
+ changes,
984
+ source,
985
+ revision: this.revision,
986
+ leaseEpoch: this.leaseEpoch,
987
+ };
713
988
  await this.emitEvents([{ type: "fs.changed", payload }]);
714
989
  }
715
990
 
@@ -754,7 +1029,10 @@ export function stripExecBanner(raw: string): string {
754
1029
  export function isWorkspaceEscapeError(error: unknown): boolean {
755
1030
  const msg = error instanceof Error ? error.message : String(error ?? "");
756
1031
  const lower = msg.toLowerCase();
757
- return lower.includes("workspace escape") || (lower.includes("remote validation") && lower.includes("escape"));
1032
+ return (
1033
+ lower.includes("workspace escape") ||
1034
+ (lower.includes("remote validation") && lower.includes("escape"))
1035
+ );
758
1036
  }
759
1037
 
760
1038
  // Detect the Modal "the exec-session you're writing to no longer exists" banner.
@@ -771,7 +1049,9 @@ export function isExecSessionLostBanner(out: string, execSessionId: number): boo
771
1049
  if (!lower.includes("session not found")) return false;
772
1050
  // When the id is present require it to match ours; when absent, the generic
773
1051
  // "session not found" still classifies (it is never legitimate stdout here).
774
- return lower.includes(`session not found: ${execSessionId}`) || !/session not found:\s*\d+/.test(lower);
1052
+ return (
1053
+ lower.includes(`session not found: ${execSessionId}`) || !/session not found:\s*\d+/.test(lower)
1054
+ );
775
1055
  }
776
1056
 
777
1057
  // Recover the numeric exec-session id the SDK embeds in a formatExecResponse
@@ -808,7 +1088,8 @@ export function assertSafeRelPath(p: string): string {
808
1088
  const norm = normalizeRelPath(p);
809
1089
  if (norm === "") throw new ChannelAValidationError("path is required");
810
1090
  if (p.startsWith("/")) throw new ChannelAValidationError(`absolute paths are not allowed: ${p}`);
811
- if (norm.split("/").some((seg) => seg === "..")) throw new ChannelAValidationError(`path traversal is not allowed: ${p}`);
1091
+ if (norm.split("/").some((seg) => seg === ".."))
1092
+ throw new ChannelAValidationError(`path traversal is not allowed: ${p}`);
812
1093
  return norm;
813
1094
  }
814
1095
 
@@ -896,12 +1177,18 @@ export function parsePorcelainV2(z: string): Omit<GitStatusResponse, "revision">
896
1177
  if (rec === "") continue;
897
1178
  if (rec.startsWith("# branch.head ")) {
898
1179
  const v = rec.slice("# branch.head ".length);
899
- if (v === "(detached)") { detached = true; head = null; } else head = v;
1180
+ if (v === "(detached)") {
1181
+ detached = true;
1182
+ head = null;
1183
+ } else head = v;
900
1184
  } else if (rec.startsWith("# branch.upstream ")) {
901
1185
  upstream = rec.slice("# branch.upstream ".length);
902
1186
  } else if (rec.startsWith("# branch.ab ")) {
903
1187
  const m = rec.slice("# branch.ab ".length).match(/\+(\d+)\s+-(\d+)/);
904
- if (m) { ahead = Number(m[1]); behind = Number(m[2]); }
1188
+ if (m) {
1189
+ ahead = Number(m[1]);
1190
+ behind = Number(m[2]);
1191
+ }
905
1192
  } else if (rec.startsWith("1 ")) {
906
1193
  // 1 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <path>
907
1194
  const fields = rec.split(" ");
@@ -919,11 +1206,29 @@ export function parsePorcelainV2(z: string): Omit<GitStatusResponse, "revision">
919
1206
  } else if (rec.startsWith("u ")) {
920
1207
  const fields = rec.split(" ");
921
1208
  const path = fields.slice(10).join(" ");
922
- files.push({ path, oldPath: null, index: "conflicted", worktree: "conflicted", isConflicted: true });
1209
+ files.push({
1210
+ path,
1211
+ oldPath: null,
1212
+ index: "conflicted",
1213
+ worktree: "conflicted",
1214
+ isConflicted: true,
1215
+ });
923
1216
  } else if (rec.startsWith("? ")) {
924
- files.push({ path: rec.slice(2), oldPath: null, index: null, worktree: "untracked", isConflicted: false });
1217
+ files.push({
1218
+ path: rec.slice(2),
1219
+ oldPath: null,
1220
+ index: null,
1221
+ worktree: "untracked",
1222
+ isConflicted: false,
1223
+ });
925
1224
  } else if (rec.startsWith("! ")) {
926
- files.push({ path: rec.slice(2), oldPath: null, index: null, worktree: "ignored", isConflicted: false });
1225
+ files.push({
1226
+ path: rec.slice(2),
1227
+ oldPath: null,
1228
+ index: null,
1229
+ worktree: "ignored",
1230
+ isConflicted: false,
1231
+ });
927
1232
  }
928
1233
  }
929
1234
  return { isRepo: true, head, detached, upstream, ahead, behind, files };
@@ -931,15 +1236,24 @@ export function parsePorcelainV2(z: string): Omit<GitStatusResponse, "revision">
931
1236
 
932
1237
  function xyCode(c: string): GitFileStatusCode | null {
933
1238
  switch (c) {
934
- case "A": return "added";
935
- case "M": return "modified";
936
- case "D": return "deleted";
937
- case "R": return "renamed";
938
- case "C": return "copied";
939
- case "T": return "typechange";
940
- case "U": return "conflicted";
941
- case ".": return null;
942
- default: return null;
1239
+ case "A":
1240
+ return "added";
1241
+ case "M":
1242
+ return "modified";
1243
+ case "D":
1244
+ return "deleted";
1245
+ case "R":
1246
+ return "renamed";
1247
+ case "C":
1248
+ return "copied";
1249
+ case "T":
1250
+ return "typechange";
1251
+ case "U":
1252
+ return "conflicted";
1253
+ case ".":
1254
+ return null;
1255
+ default:
1256
+ return null;
943
1257
  }
944
1258
  }
945
1259
 
@@ -956,17 +1270,29 @@ function statusFromXY(xy: string, path: string, oldPath: string | null): GitFile
956
1270
  }
957
1271
 
958
1272
  // ── numstat -z parser (additions/deletions/binary + rename old\0new) ─────────
959
- export type NumstatEntry = { additions: number; deletions: number; binary: boolean; oldPath: string | null; newPath: string };
1273
+ export type NumstatEntry = {
1274
+ additions: number;
1275
+ deletions: number;
1276
+ binary: boolean;
1277
+ oldPath: string | null;
1278
+ newPath: string;
1279
+ };
960
1280
  export function parseNumstatZ(z: string): NumstatEntry[] {
961
1281
  const fields = z.split(NUL);
962
1282
  const out: NumstatEntry[] = [];
963
1283
  let i = 0;
964
1284
  while (i < fields.length) {
965
1285
  const head = fields[i]!;
966
- if (head === "") { i++; continue; }
1286
+ if (head === "") {
1287
+ i++;
1288
+ continue;
1289
+ }
967
1290
  // "<add>\t<del>\t<path>" OR for a rename "<add>\t<del>\t" then old\0new follow.
968
1291
  const m = head.match(/^(\d+|-)\t(\d+|-)\t(.*)$/s);
969
- if (!m) { i++; continue; }
1292
+ if (!m) {
1293
+ i++;
1294
+ continue;
1295
+ }
970
1296
  const addStr = m[1]!;
971
1297
  const delStr = m[2]!;
972
1298
  const pathPart = m[3]!;
@@ -975,10 +1301,22 @@ export function parseNumstatZ(z: string): NumstatEntry[] {
975
1301
  // rename: the next two NUL fields are old, new
976
1302
  const oldPath = fields[i + 1] ?? null;
977
1303
  const newPath = fields[i + 2] ?? "";
978
- out.push({ additions: binary ? 0 : Number(addStr), deletions: binary ? 0 : Number(delStr), binary, oldPath, newPath });
1304
+ out.push({
1305
+ additions: binary ? 0 : Number(addStr),
1306
+ deletions: binary ? 0 : Number(delStr),
1307
+ binary,
1308
+ oldPath,
1309
+ newPath,
1310
+ });
979
1311
  i += 3;
980
1312
  } else {
981
- out.push({ additions: binary ? 0 : Number(addStr), deletions: binary ? 0 : Number(delStr), binary, oldPath: null, newPath: pathPart });
1313
+ out.push({
1314
+ additions: binary ? 0 : Number(addStr),
1315
+ deletions: binary ? 0 : Number(delStr),
1316
+ binary,
1317
+ oldPath: null,
1318
+ newPath: pathPart,
1319
+ });
982
1320
  i++;
983
1321
  }
984
1322
  }
@@ -986,7 +1324,10 @@ export function parseNumstatZ(z: string): NumstatEntry[] {
986
1324
  }
987
1325
 
988
1326
  // ── unified-diff parser -> GitDiffHunk[] (the Pierre-diff shape) ─────────────
989
- export function parseUnifiedPatch(patch: string): { hunks: GitDiffHunk[]; status: GitFileStatusCode } {
1327
+ export function parseUnifiedPatch(patch: string): {
1328
+ hunks: GitDiffHunk[];
1329
+ status: GitFileStatusCode;
1330
+ } {
990
1331
  const lines = patch.split("\n");
991
1332
  const hunks: GitDiffHunk[] = [];
992
1333
  let status: GitFileStatusCode = "modified";
@@ -1004,7 +1345,14 @@ export function parseUnifiedPatch(patch: string): { hunks: GitDiffHunk[]; status
1004
1345
  const oldLines = m[2] !== undefined ? Number(m[2]) : 1;
1005
1346
  const newStart = Number(m[3]);
1006
1347
  const newLines = m[4] !== undefined ? Number(m[4]) : 1;
1007
- current = { oldStart, oldLines, newStart, newLines, header: (m[5] ?? "").trim(), lines: [] };
1348
+ current = {
1349
+ oldStart,
1350
+ oldLines,
1351
+ newStart,
1352
+ newLines,
1353
+ header: (m[5] ?? "").trim(),
1354
+ lines: [],
1355
+ };
1008
1356
  hunks.push(current);
1009
1357
  oldNo = oldStart;
1010
1358
  newNo = newStart;