peachsim 0.1.630 → 0.1.631

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 (89) hide show
  1. package/cli/app-fonts.ts +23 -4
  2. package/cli/bridge-flow-runner.ts +35 -28
  3. package/cli/command-registry.ts +1 -0
  4. package/cli/commands/compat.ts +11 -60
  5. package/cli/commands/control.ts +12 -5
  6. package/cli/commands/detect.ts +289 -26
  7. package/cli/commands/inspect/core.ts +1 -1
  8. package/cli/commands/inspect.ts +4 -0
  9. package/cli/commands/maestro.ts +3 -1
  10. package/cli/commands/project-report.ts +386 -0
  11. package/cli/commands/projectReportContract.ts +42 -0
  12. package/cli/commands/report-issue.ts +38 -54
  13. package/cli/commands/setup.ts +6 -4
  14. package/cli/commands/telemetry.ts +8 -3
  15. package/cli/commands/test.ts +347 -134
  16. package/cli/main.ts +11 -1
  17. package/cli/outbound-endpoints.ts +2 -1
  18. package/cli/privacy.ts +1 -2
  19. package/cli/runtime-diagnostics.ts +51 -0
  20. package/cli/telemetry.ts +78 -7
  21. package/detox/index.ts +3 -8
  22. package/dist-lib/agent-daemon-client.cjs +1 -1
  23. package/dist-lib/agent-events.cjs +1 -1
  24. package/dist-lib/agent-identity.cjs +1 -1
  25. package/dist-lib/agent-sessions.cjs +1 -1
  26. package/dist-lib/attached-projects.cjs +1 -1
  27. package/dist-lib/auth/shared-session.cjs +1 -1
  28. package/dist-lib/backend-origin.cjs +1 -1
  29. package/dist-lib/beta.cjs +1 -1
  30. package/dist-lib/beta.mjs +1 -1
  31. package/dist-lib/bridge-constants.cjs +1 -1
  32. package/dist-lib/bridge-contract-input.cjs +1 -1
  33. package/dist-lib/bridge-contract-input.mjs +1 -1
  34. package/dist-lib/bridge-contract.cjs +1 -1
  35. package/dist-lib/bridge-contract.mjs +1 -1
  36. package/dist-lib/capture-contract.cjs +1 -1
  37. package/dist-lib/capture-contract.mjs +1 -1
  38. package/dist-lib/cli-constants.cjs +1 -1
  39. package/dist-lib/cloud-contract.cjs +1 -1
  40. package/dist-lib/cloud-contract.mjs +1 -1
  41. package/dist-lib/cloud-test-args.cjs +1 -1
  42. package/dist-lib/cloud-test-args.mjs +1 -1
  43. package/dist-lib/cloud.cjs +1 -1
  44. package/dist-lib/cloud.mjs +1 -1
  45. package/dist-lib/config.cjs +1 -1
  46. package/dist-lib/detox/index.cjs +2 -7
  47. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  48. package/dist-lib/home-paths.cjs +8 -5
  49. package/dist-lib/host/bridge-host.cjs +110 -67
  50. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  51. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  52. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  53. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  54. package/dist-lib/host/websocket-proxy.cjs +1 -1
  55. package/dist-lib/index.cjs +5 -1
  56. package/dist-lib/jump-to-source-babel.cjs +1 -1
  57. package/dist-lib/jump-to-source-native.cjs +1 -1
  58. package/dist-lib/kotlin-host.cjs +1 -1
  59. package/dist-lib/menu.cjs +11 -1
  60. package/dist-lib/menu.mjs +11 -1
  61. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  62. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  63. package/dist-lib/metro-production-bundle.cjs +1 -1
  64. package/dist-lib/metro-production-bundle.mjs +1 -1
  65. package/dist-lib/metro.cjs +1 -1
  66. package/dist-lib/native-app-identity.cjs +1 -1
  67. package/dist-lib/native-app-identity.mjs +1 -1
  68. package/dist-lib/profiles.cjs +1 -1
  69. package/dist-lib/public-brand.cjs +1 -1
  70. package/dist-lib/react-native-host-modules.cjs +1 -1
  71. package/dist-lib/react-native-host-modules.mjs +1 -1
  72. package/dist-lib/render-mode.cjs +1 -1
  73. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  74. package/dist-lib/sdk.cjs +3 -1
  75. package/dist-lib/sdk.mjs +2 -1
  76. package/dist-lib/skills.cjs +589 -268
  77. package/dist-lib/swift-compile.cjs +1 -1
  78. package/dist-lib/swift-compile.mjs +1 -1
  79. package/dist-lib/swift-package.cjs +1 -1
  80. package/dist-lib/swift-package.mjs +1 -1
  81. package/dist-lib/swift-submit.cjs +1 -1
  82. package/dist-lib/swift-submit.mjs +1 -1
  83. package/dist-lib/swift.cjs +439 -167
  84. package/dist-lib/vite-swift.cjs +1 -1
  85. package/dist-lib/vite.cjs +1 -1
  86. package/package.json +2 -1
  87. package/skills/contrast/SKILL.md +1 -0
  88. package/src/home-paths.ts +11 -9
  89. package/src/menu.ts +11 -0
package/cli/app-fonts.ts CHANGED
@@ -139,6 +139,16 @@ export function discoverNativeLinkedAppFonts(opts: {
139
139
  collectFontFiles(root, relativeRoot.split(path.sep).join('/'))
140
140
  }
141
141
 
142
+ // the bundle path is the entry relative to metro's root, so a bundle at
143
+ // /apps/app/index.bundle served for /repo/apps/app puts that root at /repo.
144
+ const bundleDir = path.posix
145
+ .dirname(new URL(opts.bundleUrl).pathname)
146
+ .replace(/^\/+/, '')
147
+ const appRelativeDir = bundleDir.split('/').join(path.sep)
148
+ const metroRootCandidates = [selectedAppDir]
149
+ if (bundleDir && selectedAppDir.endsWith(path.sep + appRelativeDir)) {
150
+ metroRootCandidates.push(selectedAppDir.slice(0, -appRelativeDir.length - 1))
151
+ }
142
152
  const vectorIconRequire = createRequire(path.join(selectedAppDir, 'package.json'))
143
153
  for (const dependencyName of vectorIconDependencies) {
144
154
  try {
@@ -150,10 +160,19 @@ export function discoverNativeLinkedAppFonts(opts: {
150
160
  const packageRoot = entryPath.slice(0, markerIndex + marker.length - 1)
151
161
  const fontsDir =
152
162
  dependencyName === 'react-native-vector-icons' ? 'Fonts' : 'fonts'
153
- collectFontFiles(
154
- path.join(packageRoot, fontsDir),
155
- path.posix.join('node_modules', dependencyName, fontsDir),
156
- )
163
+ // the package can sit outside the app (a hoisted workspace
164
+ // node_modules or a pnpm store). metro resolves asset paths against
165
+ // its config.projectRoot, which is the app in expo's monorepo setup
166
+ // but the repo root when metro.config sets it there, so offer the
167
+ // path from each candidate root; the reachability check keeps the
168
+ // one metro actually serves.
169
+ const fontsRoot = path.join(packageRoot, fontsDir)
170
+ for (const metroRoot of metroRootCandidates) {
171
+ collectFontFiles(
172
+ fontsRoot,
173
+ path.relative(metroRoot, fontsRoot).split(path.sep).join('/'),
174
+ )
175
+ }
157
176
  } catch {}
158
177
  }
159
178
  }
@@ -2710,38 +2710,45 @@ export class SootSimBridgeFlowRunner {
2710
2710
  }
2711
2711
 
2712
2712
  try {
2713
- // per-step watchdog: abort if nothing forward-progresses for 10s.
2714
- // catches dead bundles / frozen bridges / hung ws calls. explicit
2715
- // wait steps (extendedWaitUntil, waitFor) opt out and keep their
2716
- // own timeout — the user asked for that time; don't override.
2717
- const hasExplicitWait = !!(
2718
- step.extendedWaitUntil ||
2719
- step.waitFor ||
2720
- step.scrollUntilVisible ||
2721
- step.launchApp ||
2722
- step.runFlow ||
2723
- // retry reruns its body on failure, so its wall time is the body's
2724
- // times the retry count — the 10s no-progress watchdog is the wrong
2725
- // budget and would report "bridge probably hung" for a slow retry.
2726
- step.retry ||
2727
- // runScript http calls are synchronous with a 5-minute upstream
2728
- // timeout — the 10s watchdog would kill legitimate setup scripts.
2729
- step.runScript ||
2730
- // takeScreenshot self-governs: it retries a bounded visual settle that
2731
- // already fails with its own reason. sharing the watchdog's 10s budget
2732
- // meant the watchdog always won that race, reporting "bridge probably
2733
- // hung" for a screen that was merely still animating.
2734
- step.takeScreenshot ||
2735
- // assertions wait out maestro's lookup window, longer than 10s, and
2736
- // every query they make times out on its own, so they report their
2737
- // own failure instead of the watchdog's guess.
2738
- step.assertVisible ||
2739
- step.assertNotVisible
2713
+ // simple commands have a wall-clock watchdog. bounded operations keep
2714
+ // their own lookup, settle and bridge deadlines, so the watchdog cannot
2715
+ // report a completed step while its action continues in the background.
2716
+ const hasBoundedWait = !!(
2717
+ // taps combine bounded lookup, settling and delivery. a wall-clock
2718
+ // watchdog can finish the step while that action is still running.
2719
+ // let these operations finish before starting the next command.
2720
+ (
2721
+ step.tapOn ||
2722
+ step.doubleTapOn ||
2723
+ step.longPressOn ||
2724
+ step.extendedWaitUntil ||
2725
+ step.waitFor ||
2726
+ step.scrollUntilVisible ||
2727
+ step.launchApp ||
2728
+ step.runFlow ||
2729
+ // retry reruns its body on failure, so its wall time is the body's
2730
+ // times the retry count — the 10s no-progress watchdog is the wrong
2731
+ // budget and would report "bridge probably hung" for a slow retry.
2732
+ step.retry ||
2733
+ // runScript http calls are synchronous with a 5-minute upstream
2734
+ // timeout — the 10s watchdog would kill legitimate setup scripts.
2735
+ step.runScript ||
2736
+ // takeScreenshot self-governs: it retries a bounded visual settle that
2737
+ // already fails with its own reason. sharing the watchdog's 10s budget
2738
+ // meant the watchdog always won that race, reporting "bridge probably
2739
+ // hung" for a screen that was merely still animating.
2740
+ step.takeScreenshot ||
2741
+ // assertions wait out maestro's lookup window, longer than 10s, and
2742
+ // every query they make times out on its own, so they report their
2743
+ // own failure instead of the watchdog's guess.
2744
+ step.assertVisible ||
2745
+ step.assertNotVisible
2746
+ )
2740
2747
  )
2741
2748
  const STEP_WATCHDOG_MS = 10_000
2742
2749
  const body = this.runStepInner(step)
2743
2750
  let screenshotPath: string | undefined
2744
- if (hasExplicitWait) {
2751
+ if (hasBoundedWait) {
2745
2752
  const result = await body
2746
2753
  if (typeof result === 'string') screenshotPath = result
2747
2754
  } else {
@@ -48,6 +48,7 @@ const NODE_COMMAND_NAMES = [
48
48
  'claim',
49
49
  'close',
50
50
  'compat',
51
+ 'project-report',
51
52
  'report-issue',
52
53
  'desktop',
53
54
  'login',
@@ -1,7 +1,6 @@
1
1
  // peach compat — scan dependencies with the shared compatibility engine.
2
2
 
3
- import { existsSync, readFileSync } from 'node:fs'
4
- import { dirname, join, relative, resolve } from 'node:path'
3
+ import { relative, resolve } from 'node:path'
5
4
  import {
6
5
  COMPAT_RESULT_CAVEAT,
7
6
  formatDetailedScanLines,
@@ -9,13 +8,13 @@ import {
9
8
  scanDeps,
10
9
  type ScanResult,
11
10
  } from '@peach/compat/web'
12
- import { findProjectRoot, scanWorkspace } from './detect'
13
-
14
- interface PackageManifest {
15
- name?: string
16
- dependencies?: Record<string, string>
17
- devDependencies?: Record<string, string>
18
- }
11
+ import {
12
+ findProjectRoot,
13
+ scanWorkspace,
14
+ installedManifest,
15
+ readProjectManifest,
16
+ } from './detect'
17
+ import type { ProjectManifest } from './projectReportContract'
19
18
 
20
19
  export async function runCompat(args: string[]): Promise<void> {
21
20
  if (args.includes('--help') || args.includes('-h')) {
@@ -41,7 +40,7 @@ flags:
41
40
  const packageName = args.find(
42
41
  (arg, index) => !arg.startsWith('-') && (appIndex < 0 || index !== appIndex + 1),
43
42
  )
44
- const pkg = readPackageJson(appDir)
43
+ const pkg = readProjectManifest(appDir)
45
44
  if (!pkg) {
46
45
  console.error(` no package.json found in ${appDir}`)
47
46
  process.exitCode = 1
@@ -65,7 +64,7 @@ flags:
65
64
  const workspaceRoot = findProjectRoot(appDir)
66
65
  const elsewhere = scanWorkspace(workspaceRoot).apps.flatMap((app) => {
67
66
  if (resolve(app.dir) === appDir) return []
68
- const manifest = readPackageJson(app.dir)
67
+ const manifest = readProjectManifest(app.dir)
69
68
  if (!manifest) return []
70
69
  const result = scanDeps(installedManifest(app.dir, manifest))
71
70
  return result.packages.length > 0 ? [{ dir: app.dir, result }] : []
@@ -114,57 +113,9 @@ function report(args: string[], result: ScanResult): void {
114
113
  console.log(`\n ${COMPAT_RESULT_CAVEAT}\n`)
115
114
  }
116
115
 
117
- // the declared range is often not a version: pnpm and bun catalogs write
118
- // `catalog:`, a workspace dependency writes `workspace:*`, and a pinned tarball
119
- // writes a URL. none of those match a verified version range, so a whole app
120
- // reports as unverified. the installed tree holds the version that will
121
- // actually run, so ask it, and keep the declared range for anything the project
122
- // has not installed.
123
- function installedManifest(appDir: string, pkg: PackageManifest): PackageManifest {
124
- const resolveVersion = (name: string, declared: string): string => {
125
- let dir = appDir
126
- while (true) {
127
- const manifest = join(dir, 'node_modules', name, 'package.json')
128
- if (existsSync(manifest)) {
129
- try {
130
- const parsed: unknown = JSON.parse(readFileSync(manifest, 'utf8'))
131
- if (parsed && typeof parsed === 'object' && 'version' in parsed) {
132
- const version = parsed.version
133
- if (typeof version === 'string') return version
134
- }
135
- } catch {}
136
- return declared
137
- }
138
- const parent = dirname(dir)
139
- if (parent === dir) return declared
140
- dir = parent
141
- }
142
- }
143
- const mapped = (deps: Record<string, string> | undefined) =>
144
- deps &&
145
- Object.fromEntries(
146
- Object.entries(deps).map(([name, declared]) => [
147
- name,
148
- resolveVersion(name, declared),
149
- ]),
150
- )
151
- return {
152
- name: pkg.name,
153
- dependencies: mapped(pkg.dependencies),
154
- devDependencies: mapped(pkg.devDependencies),
155
- }
156
- }
157
-
158
- function readPackageJson(appDir: string): PackageManifest | null {
159
- const packagePath = resolve(appDir, 'package.json')
160
- if (!existsSync(packagePath)) return null
161
- const parsed: unknown = JSON.parse(readFileSync(packagePath, 'utf8'))
162
- return parsed && typeof parsed === 'object' ? parsed : null
163
- }
164
-
165
116
  function scanOnePackage(
166
117
  appDir: string,
167
- pkg: PackageManifest,
118
+ pkg: ProjectManifest,
168
119
  packageName: string,
169
120
  ): ScanResult {
170
121
  const installed = installedManifest(appDir, pkg)
@@ -73,7 +73,9 @@ import {
73
73
  } from '../current-sim'
74
74
  import { getDriver } from '../drivers/registry'
75
75
  import { openUrl as openUrlInBrowser } from '../open-url'
76
+ import { diagnosticsAllowedForCurrentProcess } from '../privacy'
76
77
  import { rethrowIfExit, rnxExit } from '../run-rnx'
78
+ import { collectRuntimeDiagnostics } from '../runtime-diagnostics'
77
79
  import { rnxSelfInvocation } from '../self-invocation'
78
80
  import {
79
81
  BridgeSimLockedError,
@@ -695,11 +697,13 @@ async function filterReachableAppFonts(
695
697
  }
696
698
  }),
697
699
  )
698
- const reachable: DiscoveredAppFontSpec[] = []
700
+ // a font offered from more than one candidate metro root keeps the first
701
+ // url metro served.
702
+ const reachable = new Map<string, DiscoveredAppFontSpec>()
699
703
  for (const spec of results) {
700
- if (spec !== null) reachable.push(spec)
704
+ if (spec !== null && !reachable.has(spec.family)) reachable.set(spec.family, spec)
701
705
  }
702
- return reachable
706
+ return [...reachable.values()]
703
707
  }
704
708
 
705
709
  async function resolveShellUrl(
@@ -1293,7 +1297,8 @@ function printAgentListenHint(quiet: boolean): void {
1293
1297
  // a splash; the caller can `peach describe` again a second later. for
1294
1298
  // warm loads it's the real screen. guarded by --no-describe.
1295
1299
  async function maybeDescribeAfterOpen(wsPort: number, simId: string, args: string[]) {
1296
- if (args.includes('--no-describe')) return
1300
+ const noDescribe = args.includes('--no-describe')
1301
+ if (noDescribe && !diagnosticsAllowedForCurrentProcess()) return
1297
1302
  const prevQuiet = process.env.SOOTSIM_QUIET_TARGET_NOTICE
1298
1303
  process.env.SOOTSIM_QUIET_TARGET_NOTICE = '1'
1299
1304
  try {
@@ -1302,7 +1307,7 @@ async function maybeDescribeAfterOpen(wsPort: number, simId: string, args: strin
1302
1307
  // seconds on a still-routing guest app, which is exactly what we want
1303
1308
  // to skip for the open-side describe. caller can `peach describe`
1304
1309
  // again later for post-routing state.
1305
- await waitForTreeStable(wsPort, simId, { stableMs: 150, maxMs: 400 })
1310
+ if (!noDescribe) await waitForTreeStable(wsPort, simId, { stableMs: 150, maxMs: 400 })
1306
1311
  const bridge = createBridge(wsPort, {
1307
1312
  commandTimeoutMs: 3000,
1308
1313
  simId,
@@ -1310,6 +1315,7 @@ async function maybeDescribeAfterOpen(wsPort: number, simId: string, args: strin
1310
1315
  simIdSource: 'flag',
1311
1316
  })
1312
1317
  try {
1318
+ if (noDescribe) return
1313
1319
  let readyProbe: Awaited<ReturnType<typeof inspectWaitReady>> | null = null
1314
1320
  try {
1315
1321
  readyProbe = await inspectWaitReady(bridge, 1)
@@ -1341,6 +1347,7 @@ async function maybeDescribeAfterOpen(wsPort: number, simId: string, args: strin
1341
1347
  console.log('')
1342
1348
  console.log(result.tree)
1343
1349
  } finally {
1350
+ await collectRuntimeDiagnostics(bridge)
1344
1351
  bridge.close()
1345
1352
  }
1346
1353
  } catch {
@@ -1,11 +1,21 @@
1
1
  // project detection — finds project root, detects framework, scans monorepo workspaces
2
2
 
3
- import { existsSync, readFileSync, readdirSync, statSync } from 'fs'
4
- import { basename, dirname, join } from 'path'
3
+ import {
4
+ existsSync,
5
+ lstatSync,
6
+ opendirSync,
7
+ readFileSync,
8
+ readdirSync,
9
+ statSync,
10
+ } from 'node:fs'
11
+ import { basename, dirname, join } from 'node:path'
12
+ import JSON5 from 'json5'
13
+ import { parse as parseYaml } from 'yaml'
14
+ import type { DependencyVersion, ProjectManifest } from './projectReportContract'
5
15
 
6
16
  export type Framework = 'one' | 'expo' | 'bare' | 'unknown'
7
17
  export type PackageManager = 'bun' | 'pnpm' | 'npm' | 'yarn'
8
- export type MonorepoType = 'turbo' | 'pnpm' | 'nx' | 'npm-workspaces' | 'single'
18
+ export type MonorepoType = 'turbo' | 'pnpm' | 'nx' | 'lerna' | 'npm-workspaces' | 'single'
9
19
 
10
20
  export interface DetectedProject {
11
21
  dir: string
@@ -25,7 +35,8 @@ export interface DetectedWorkspace {
25
35
 
26
36
  const CONFIG_EXTENSIONS = ['ts', 'js', 'cjs', 'mjs', 'mts', 'cts']
27
37
 
28
- export function findProjectRoot(cwd: string): string {
38
+ export function findProjectRoot(cwd: string, includeNativeRepository = false): string {
39
+ let swiftRoot: string | undefined
29
40
  let dir = cwd
30
41
  while (true) {
31
42
  if (
@@ -37,22 +48,30 @@ export function findProjectRoot(cwd: string): string {
37
48
  return dir
38
49
  }
39
50
 
40
- const pkgPath = join(dir, 'package.json')
41
- if (existsSync(pkgPath)) {
42
- try {
43
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
44
- if (pkg.workspaces) return dir
45
- } catch {}
46
- }
51
+ if (metadataValue(readProjectJson(join(dir, 'package.json')), 'workspaces'))
52
+ return dir
47
53
 
54
+ if (includeNativeRepository) {
55
+ if (existsSync(join(dir, '.git'))) return dir
56
+ if (!swiftRoot && existsSync(join(dir, 'Package.swift'))) swiftRoot = dir
57
+ }
48
58
  const parent = dirname(dir)
49
59
  if (parent === dir) break
50
60
  dir = parent
51
61
  }
52
- return cwd
62
+ return swiftRoot ?? cwd
53
63
  }
54
64
 
55
65
  export function detectPackageManager(root: string): PackageManager {
66
+ const manager = metadataValue(
67
+ readProjectJson(join(root, 'package.json')),
68
+ 'packageManager',
69
+ )
70
+ if (typeof manager === 'string') {
71
+ const name = manager.split('@')[0]
72
+ if (name === 'bun' || name === 'pnpm' || name === 'npm' || name === 'yarn')
73
+ return name
74
+ }
56
75
  if (existsSync(join(root, 'bun.lock')) || existsSync(join(root, 'bun.lockb'))) {
57
76
  return 'bun'
58
77
  }
@@ -66,13 +85,9 @@ export function detectMonorepoType(root: string): MonorepoType {
66
85
  if (existsSync(join(root, 'nx.json'))) return 'nx'
67
86
  if (existsSync(join(root, 'pnpm-workspace.yaml'))) return 'pnpm'
68
87
 
69
- const pkgPath = join(root, 'package.json')
70
- if (existsSync(pkgPath)) {
71
- try {
72
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
73
- if (pkg.workspaces) return 'npm-workspaces'
74
- } catch {}
75
- }
88
+ if (existsSync(join(root, 'lerna.json'))) return 'lerna'
89
+ if (metadataValue(readProjectJson(join(root, 'package.json')), 'workspaces'))
90
+ return 'npm-workspaces'
76
91
  return 'single'
77
92
  }
78
93
 
@@ -87,14 +102,14 @@ export function detectProject(dir: string): DetectedProject | null {
87
102
  const pkgPath = join(dir, 'package.json')
88
103
  if (!existsSync(pkgPath)) return null
89
104
 
90
- let pkg: any
91
- try {
92
- pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
93
- } catch {
94
- return null
95
- }
105
+ const pkg = readProjectManifest(dir)
106
+ if (!pkg) return null
96
107
 
97
108
  const deps = { ...pkg.dependencies, ...pkg.devDependencies }
109
+ const devCommand = metadataValue(
110
+ metadataValue(readProjectJson(pkgPath), 'scripts'),
111
+ 'dev',
112
+ )
98
113
  const framework = detectFramework(deps)
99
114
 
100
115
  if (framework === 'unknown') return null
@@ -112,7 +127,7 @@ export function detectProject(dir: string): DetectedProject | null {
112
127
  hasMetroConfig: CONFIG_EXTENSIONS.some((ext) =>
113
128
  existsSync(join(dir, `metro.config.${ext}`)),
114
129
  ),
115
- devCommand: pkg.scripts?.dev || null,
130
+ devCommand: typeof devCommand === 'string' ? devCommand : null,
116
131
  }
117
132
  }
118
133
 
@@ -199,3 +214,251 @@ export function scanWorkspace(root: string): DetectedWorkspace {
199
214
 
200
215
  return { root, type, packageManager, apps }
201
216
  }
217
+
218
+ // reports inspect metadata without evaluating app config or following directory
219
+ // symlinks. this also finds native projects outside package-manager workspaces.
220
+ const REPORT_SKIP_DIRS = new Set([
221
+ 'node_modules',
222
+ 'Pods',
223
+ 'Carthage',
224
+ 'build',
225
+ 'dist',
226
+ 'dist-lib',
227
+ 'DerivedData',
228
+ 'vendor',
229
+ 'coverage',
230
+ 'target',
231
+ 'tmp',
232
+ 'cache',
233
+ 'artifacts',
234
+ 'test-results',
235
+ 'playwright-report',
236
+ ])
237
+
238
+ export function scanRepository(root: string): {
239
+ directories: string[]
240
+ truncated: boolean
241
+ unreadable: number
242
+ } {
243
+ const directories: string[] = []
244
+ const pending = [{ dir: root, depth: 0 }]
245
+ let entries = 0
246
+ let cursor = 0
247
+ let truncated = false
248
+ let unreadable = 0
249
+ while (cursor < pending.length && entries < 200_000 && directories.length < 10_000) {
250
+ const next = pending[cursor++]
251
+ if (!next) break
252
+ directories.push(next.dir)
253
+ let handle: ReturnType<typeof opendirSync>
254
+ try {
255
+ handle = opendirSync(next.dir)
256
+ } catch {
257
+ unreadable++
258
+ continue
259
+ }
260
+ try {
261
+ let entry
262
+ while (entries < 200_000 && (entry = handle.readSync())) {
263
+ entries++
264
+ if (
265
+ !entry.isDirectory() ||
266
+ entry.name.startsWith('.') ||
267
+ REPORT_SKIP_DIRS.has(entry.name)
268
+ )
269
+ continue
270
+ if (next.depth >= 12) {
271
+ truncated = true
272
+ continue
273
+ }
274
+ pending.push({ dir: join(next.dir, entry.name), depth: next.depth + 1 })
275
+ }
276
+ } finally {
277
+ handle.closeSync()
278
+ }
279
+ }
280
+ return {
281
+ directories: directories.sort(),
282
+ truncated: truncated || cursor < pending.length || entries >= 200_000,
283
+ unreadable,
284
+ }
285
+ }
286
+
287
+ // inspect only regular, bounded metadata files. errors are data, never a reason
288
+ // to execute project code. symlinks may point outside the checkout.
289
+ export function readProjectMetadata(path: string): string | null {
290
+ try {
291
+ const info = lstatSync(path)
292
+ if (!info.isFile() || info.size > 4 * 1024 * 1024) return null
293
+ return readFileSync(path, 'utf8')
294
+ } catch {
295
+ return null
296
+ }
297
+ }
298
+
299
+ export function metadataValue(value: unknown, key: string): unknown {
300
+ if (!value || typeof value !== 'object') return undefined
301
+ return Object.getOwnPropertyDescriptor(value, key)?.value
302
+ }
303
+
304
+ export function readProjectJson(path: string): unknown {
305
+ const text = readProjectMetadata(path)
306
+ if (text === null) return null
307
+ try {
308
+ const value: unknown = JSON.parse(text)
309
+ return value
310
+ } catch {
311
+ return null
312
+ }
313
+ }
314
+
315
+ export function readProjectManifest(dir: string): ProjectManifest | null {
316
+ const parsed = readProjectJson(join(dir, 'package.json'))
317
+ if (!parsed || typeof parsed !== 'object') return null
318
+ const record = (value: unknown): Record<string, string> => {
319
+ if (!value || typeof value !== 'object') return {}
320
+ return Object.fromEntries(
321
+ Object.entries(value).filter(
322
+ (entry): entry is [string, string] =>
323
+ typeof entry[1] === 'string' &&
324
+ /^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+$/i.test(entry[0]) &&
325
+ entry[0] !== '.' &&
326
+ entry[0] !== '..',
327
+ ),
328
+ )
329
+ }
330
+ const name = metadataValue(parsed, 'name')
331
+ return {
332
+ ...(typeof name === 'string' ? { name } : {}),
333
+ dependencies: record(metadataValue(parsed, 'dependencies')),
334
+ devDependencies: record(metadataValue(parsed, 'devDependencies')),
335
+ }
336
+ }
337
+
338
+ // resolve each dependency at its nearest installation/lockfile boundary, so an
339
+ // uninstalled nested project never inherits another app's hoisted version.
340
+ export function resolveManifestVersions(
341
+ dir: string,
342
+ manifest: ProjectManifest,
343
+ locks = new Map<string, Map<string, string>>(),
344
+ ): Record<string, DependencyVersion> {
345
+ const resolveVersion = (name: string, declared: string): DependencyVersion => {
346
+ if (!/^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+$/i.test(name))
347
+ return { version: declared, source: 'declared' }
348
+ let current = dir
349
+ while (true) {
350
+ // installed packages commonly use symlinks; read just this known manifest,
351
+ // never traverse their dependency trees.
352
+ try {
353
+ const file = join(current, 'node_modules', name, 'package.json')
354
+ if (statSync(file).size <= 256 * 1024) {
355
+ const value: unknown = JSON.parse(readFileSync(file, 'utf8'))
356
+ const version = metadataValue(value, 'version')
357
+ if (typeof version === 'string') return { version, source: 'installed' }
358
+ }
359
+ } catch {}
360
+ let lock = locks.get(current)
361
+ if (!lock) {
362
+ lock = readLockedVersions(current)
363
+ locks.set(current, lock)
364
+ }
365
+ const version = lock.get(name)
366
+ if (version) return { version, source: 'lockfile' }
367
+ if (
368
+ [
369
+ 'bun.lock',
370
+ 'bun.lockb',
371
+ 'package-lock.json',
372
+ 'pnpm-lock.yaml',
373
+ 'yarn.lock',
374
+ ].some((file) => existsSync(join(current, file)))
375
+ )
376
+ break
377
+ const parent = dirname(current)
378
+ if (parent === current) break
379
+ current = parent
380
+ }
381
+ return { version: declared, source: 'declared' }
382
+ }
383
+ return Object.fromEntries(
384
+ Object.entries({ ...manifest.dependencies, ...manifest.devDependencies }).map(
385
+ ([name, declared]) => [name, resolveVersion(name, declared)],
386
+ ),
387
+ )
388
+ }
389
+
390
+ function readLockedVersions(dir: string): Map<string, string> {
391
+ const versions = new Map<string, string>()
392
+ const put = (name: string, value: unknown) => {
393
+ if (typeof value !== 'string') return
394
+ // multiple resolutions without an installed tree are ambiguous; report the
395
+ // declared range rather than choosing a transitive package's version.
396
+ const before = versions.get(name)
397
+ versions.set(name, before === undefined || before === value ? value : '')
398
+ }
399
+ const bun = readProjectMetadata(join(dir, 'bun.lock'))
400
+ if (bun) {
401
+ try {
402
+ const rootVersions = new Map<string, string>()
403
+ const parsed: unknown = JSON5.parse(bun)
404
+ const packages = metadataValue(parsed, 'packages')
405
+ if (packages && typeof packages === 'object') {
406
+ for (const [key, tuple] of Object.entries(packages)) {
407
+ if (!Array.isArray(tuple) || typeof tuple[0] !== 'string') continue
408
+ const match = /^((?:@[^/]+\/)?[^@]+)@([\d][\w.+-]*)$/.exec(tuple[0])
409
+ if (match) put(match[1], match[2])
410
+ // bun's root resolution wins over nested duplicate package keys.
411
+ if (match && key === match[1]) rootVersions.set(match[1], match[2])
412
+ }
413
+ }
414
+ for (const [name, version] of rootVersions) versions.set(name, version)
415
+ } catch {}
416
+ }
417
+ const npm = metadataValue(readProjectJson(join(dir, 'package-lock.json')), 'packages')
418
+ if (npm && typeof npm === 'object') {
419
+ for (const [key, value] of Object.entries(npm)) {
420
+ if (key.startsWith('node_modules/') && !key.slice(13).includes('/node_modules/'))
421
+ put(key.slice(13), metadataValue(value, 'version'))
422
+ }
423
+ }
424
+ const pnpm = readProjectMetadata(join(dir, 'pnpm-lock.yaml'))
425
+ if (pnpm) {
426
+ try {
427
+ const parsed: unknown = parseYaml(pnpm)
428
+ const packages = metadataValue(parsed, 'packages')
429
+ if (packages && typeof packages === 'object')
430
+ for (const key of Object.keys(packages)) {
431
+ const match = /^\/?((?:@[^/]+\/)?[^@]+)@([\d][\w.+-]*)/.exec(key)
432
+ if (match) put(match[1], match[2])
433
+ }
434
+ } catch {}
435
+ }
436
+ const yarn = readProjectMetadata(join(dir, 'yarn.lock'))
437
+ if (yarn) {
438
+ let names: string[] = []
439
+ for (const line of yarn.split('\n')) {
440
+ if (/^\S.*:\s*$/.test(line))
441
+ names = [...line.matchAll(/(?:^|[", ]+)((?:@[^/\s]+\/)?[^@" ,]+)@/g)].map(
442
+ (match) => match[1],
443
+ )
444
+ const version = /^\s+version[ :]+["']?([\d][\w.+-]*)/.exec(line)
445
+ if (version) for (const name of names) put(name, version[1])
446
+ }
447
+ }
448
+ return versions
449
+ }
450
+
451
+ export function installedManifest(
452
+ dir: string,
453
+ manifest: ProjectManifest,
454
+ ): ProjectManifest {
455
+ const versions = resolveManifestVersions(dir, manifest)
456
+ const mapped = (deps: Record<string, string> | undefined) =>
457
+ deps &&
458
+ Object.fromEntries(Object.keys(deps).map((name) => [name, versions[name].version]))
459
+ return {
460
+ name: manifest.name,
461
+ dependencies: mapped(manifest.dependencies),
462
+ devDependencies: mapped(manifest.devDependencies),
463
+ }
464
+ }
@@ -1287,7 +1287,7 @@ export interface ConsoleEntry {
1287
1287
  // One root error boundary in the tenant worker) can be live and screaming in
1288
1288
  // the driver's console while the agent's `sootsim_errors` reports nothing.
1289
1289
  // Merge both, dedup, so worker + root-error-boundary failures always surface.
1290
- function consoleEntriesEval(level: 'error' | 'warn', limit: number): string {
1290
+ export function consoleEntriesEval(level: 'error' | 'warn', limit: number): string {
1291
1291
  const getter = level === 'error' ? 'getErrors' : 'getWarnings'
1292
1292
  return `(() => {
1293
1293
  const out = []